Imports become components; STEP assemblies as nested components (#1386) (#1426)

* Imports become components: a STEP assembly's tree as nested components (#1386)

Every imported file (STEP, IGES, 3MF, STL, OBJ: inserted, dropped, opened as a
design, or file.insert_step / file.insert_mesh / doc.open from scripts, the CLI
and MCP) now comes in as a new component named after the file, with its Import
feature inside it. A STEP assembly's products become nested components; a part
placed more than once is one component with an occurrence per placement, and
the Import feature skips the file's later copies (`skip`, format 21 note). The
contents are held as one rigid group (rigid joints, glyphs hidden); moving an
inner part is refused until component.unlock ("Unlock Contents") releases it.
Y up turns the import's component. Replies report component, component_name
and occurrence. Undo is one step; designs with loose imports are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep an import's rigid group hidden when the window loads the file in the background

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Import instances' skip list is format 22: format 21 ships in 0.12.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Catalog entries for Unlock Contents

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bherbruckandClaude Opus 5.5 authored and GitHub committed 2026-10-15 11:04:49 -04:00
1 parent d43c414b7b
commit 27529d9742
37 files changed
+968 -103

No files matched your search

+5
View File
@@ -1475,6 +1475,11 @@ pub enum FeatureKind {
/// The file's product/assembly tree, kept for components.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
components: Vec<solidcraft_kernel::StepNode>,
/// The file's bodies (by index) this feature does not build: later instances of a part
/// the file places more than once, shown by the other occurrences of that part's
/// component (#1386). Empty in designs from before format 22: every body is built.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
skip: Vec<u64>,
},
/// Rebuild an upstream constant-section mesh with analytic planar/cylindrical faces.
MeshConvert {
+25 -7
View File
@@ -4681,15 +4681,12 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
}
Ok(())
}
FeatureKind::Import { step, .. } => {
FeatureKind::Import { step, skip, .. } => {
let imp = kernel::step_import_shared(step)?;
let digest = crate::config::payload_digest(step);
// Files from board and assembly tools give thousands of bodies the same name: name
// them from a set, not by searching the bodies for every candidate (cubic).
let mut names = BodyNames::new(st);
for (k, b) in imp.bodies.iter().enumerate() {
let base = f.body_names.get(k).filter(|n| !n.trim().is_empty()).unwrap_or(&b.name);
let name = names.unique(base);
let names = import_body_names(st, &f.body_names, &imp, skip);
for (b, name) in imp.bodies.iter().zip(names) {
let Some(name) = name else { continue };
let b = crate::lineage::numbered_tool(b.body.clone().with_import_certificate(&digest, f.id)?);
st.bodies.push(ModelBody::traced_new(name, b, f.id));
}
@@ -4722,6 +4719,27 @@ fn mesh_bodies(meshes: &[crate::MeshData]) -> Result<Vec<(String, Body)>> {
Ok(out)
}
/// The names an Import feature gives the file's bodies, added after the bodies of `st`, in the
/// file's order: `None` for a body it skips (a later instance of a repeated part). `body_names`
/// are the feature's stored names (by index), else the file's own.
pub fn import_body_names(st: &ModelState, body_names: &[String], imp: &kernel::StepImport, skip: &[u64]) -> Vec<Option<String>> {
let skip: std::collections::HashSet<u64> = skip.iter().copied().collect();
// Files from board and assembly tools give thousands of bodies the same name: name them
// from a set, not by searching the bodies for every candidate (cubic).
let mut names = BodyNames::new(st);
imp.bodies
.iter()
.enumerate()
.map(|(k, b)| {
if skip.contains(&(k as u64)) {
return None;
}
let base = body_names.get(k).filter(|n| !n.trim().is_empty()).unwrap_or(&b.name);
Some(names.unique(base))
})
.collect()
}
/// [`unique_body_name`] for many bodies in a row: the same names, in time linear in the bodies.
struct BodyNames {
taken: std::collections::HashSet<String>,
+11 -2
View File
@@ -200,13 +200,21 @@
//! and a bend ending beside more of the fixed face its bend relief (0.11.0 left every corner a
//! notch and cut no bend relief), and a wall on part of an edge bends only where it stands
//! (0.11.0 refused it).
//! - 22: an Import (STEP) feature can list `skip`: indices of the file's bodies it does not
//! build, because they are later instances of a part the file places more than once and the
//! other occurrences of that part's component show them (#1386: an imported file becomes a
//! component, an assembly in it nested components). Version 21 designs need no
//! transformation: an Import without it builds every body, as it always did, and designs
//! whose imports sit loose in a component are left as they are. The version stamp makes
//! 0.12.0 (which reads up to 21) ask for an update instead of building those instances again
//! on top of their occurrences.
use serde_json::Value;
use crate::{DocError, Document, Result};
/// The format this build writes.
pub const FORMAT: u32 = 21;
pub const FORMAT: u32 = 22;
pub fn format_string(n: u32) -> String {
format!("solidcraft/{n}")
@@ -223,7 +231,7 @@ pub fn version_of(s: &str) -> Option<u32> {
/// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`.
type JsonStep = fn(&mut Value) -> Result<()>;
const JSON_STEPS: [JsonStep; 20] = [
const JSON_STEPS: [JsonStep; 21] = [
|_| Ok(()),
v2_to_v3,
v3_to_v4,
@@ -244,6 +252,7 @@ const JSON_STEPS: [JsonStep; 20] = [
|_| Ok(()),
|_| Ok(()),
|_| Ok(()),
|_| Ok(()),
];
/// Stored face and edge names were worked out from geometry before format 14; they get the
+10 -1
View File
@@ -1054,9 +1054,18 @@ pub fn solve_holding(doc: &Document, st: &ModelState, held: &Held) -> Solution {
Tree { joints: asm.joints.iter().map(|j| (j.id, j)).collect(), frames, fixed: placed.clone(), parent: BTreeMap::new(), order: Vec::new() };
let mut used: BTreeSet<u64> = BTreeSet::new();
let mut queue: VecDeque<u64> = placed.keys().copied().collect();
// The joints at each occurrence, in the joints' order (a large assembly is not walked joint by
// joint for every occurrence).
let mut at: BTreeMap<u64, Vec<&Joint>> = BTreeMap::new();
for j in joints.iter().copied() {
at.entry(j.a.occurrence).or_default().push(j);
if j.b.occurrence != j.a.occurrence {
at.entry(j.b.occurrence).or_default().push(j);
}
}
while let Some(occ) = queue.pop_front() {
let Some(t) = placed.get(&occ).copied() else { continue };
for j in &joints {
for j in at.get(&occ).map(Vec::as_slice).unwrap_or_default() {
if used.contains(&j.id) || !tree.frames.contains_key(&j.id) {
continue;
}
+3 -3
View File
@@ -56,9 +56,9 @@ pub use assembly::{
pub use document::*;
pub use eval::{
EdgeRef, FeatureResult, Model, ModelBody, ModelState, PlacedBodies, RevolveExtent, SolvedSketch, ThreadInfo, curve_pick_error, edge_names_for,
edge_refs, face_point_names, face_point_state, hole_direction_at, object_height, parse_metric_thread, pattern_matrices, revolve_extent,
revolve_extent_with_convention, select_profiles, sketch_hole_direction, state_in_frame, state_in_frame_at, through_all_distance, world_state,
world_state_reusing,
edge_refs, face_point_names, face_point_state, hole_direction_at, import_body_names, object_height, parse_metric_thread, pattern_matrices,
revolve_extent, revolve_extent_with_convention, select_profiles, sketch_hole_direction, state_in_frame, state_in_frame_at, through_all_distance,
world_state, world_state_reusing,
};
pub use params::{ParamRow, ParamUser, cycles};
pub use solidcraft_kernel as kernel;