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

+324
View File
@@ -0,0 +1,324 @@
//! An imported file becomes a component named after it (#1386): a STEP part is one component
//! with its body, a STEP assembly nested components that mirror the file with a repeated part's
//! placements as occurrences of one component, held together as one rigid group until it is
//! unlocked; a mesh file one component. Undo takes the whole import away, and designs whose
//! imports sit loose in a component open and rebuild as they did.
use serde_json::{Value, json};
use solidcraft_geom::Vec3;
use solidcraft_kernel::{ExportBody, ExportProduct, StepHeader, box_solid, step_export_products};
use super::run;
use crate::*;
fn temp_dir(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("solidcraft-import-component-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn at(x: f64, y: f64, z: f64) -> [[f64; 4]; 4] {
[[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [x, y, z, 1.0]]
}
/// A quarter turn about Z, then moved to (x, y, z).
fn turned(x: f64, y: f64, z: f64) -> [[f64; 4]; 4] {
[[0.0, 1.0, 0.0, 0.0], [-1.0, 0.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [x, y, z, 1.0]]
}
/// `Rig`: a base plate, two `Arm` sub-assemblies (each a 20 × 4 × 4 beam and a `Pin`) and a third
/// `Pin` on its own; one arm is turned a quarter turn. Pins: 2 × 2 × 10. Six bodies in the
/// file: four products, one of them a sub-assembly placed twice.
fn rig_file(dir: &std::path::Path) -> String {
let plate = box_solid(Vec3::ZERO, Vec3::new(100.0, 60.0, 5.0)).unwrap();
let beam = box_solid(Vec3::ZERO, Vec3::new(20.0, 4.0, 4.0)).unwrap();
let pin = box_solid(Vec3::ZERO, Vec3::new(2.0, 2.0, 10.0)).unwrap();
let part = |name: &str, body| ExportProduct {
name: name.into(),
bodies: vec![ExportBody { name: name.into(), body, color: None }],
children: Vec::new(),
};
let products = vec![
ExportProduct {
name: "Rig".into(),
bodies: Vec::new(),
children: vec![
(1, at(0.0, 0.0, 0.0), "Base-1".into()),
(2, at(10.0, 10.0, 5.0), "Arm-1".into()),
(2, turned(60.0, 10.0, 5.0), "Arm-2".into()),
(4, at(90.0, 50.0, 5.0), "Pin-3".into()),
],
},
part("Base", &plate),
ExportProduct {
name: "Arm".into(),
bodies: Vec::new(),
children: vec![(3, at(0.0, 0.0, 0.0), "Beam-1".into()), (4, at(18.0, 1.0, 4.0), "Pin-1".into())],
},
part("Beam", &beam),
part("Pin", &pin),
];
let text = step_export_products(&products, 0, &StepHeader::default()).unwrap();
let path = dir.join("rig.step");
std::fs::write(&path, text).unwrap();
path.to_string_lossy().to_string()
}
/// Where the file itself puts its bodies: their bounding boxes, sorted.
fn file_boxes(path: &str) -> Vec<[i64; 6]> {
let imp = solidcraft_kernel::step_import(&std::fs::read_to_string(path).unwrap()).unwrap();
let mut out: Vec<[i64; 6]> = imp.bodies.iter().map(|b| key(solidcraft_kernel::measure(&b.body).unwrap().bbox)).collect();
out.sort();
out
}
fn key(b: solidcraft_geom::Aabb3) -> [i64; 6] {
let r = |v: f64| (v * 1000.0).round() as i64;
[r(b.min.x), r(b.min.y), r(b.min.z), r(b.max.x), r(b.max.y), r(b.max.z)]
}
/// Where the design shows its bodies, sorted.
fn shown_boxes(s: &Session) -> Vec<[i64; 6]> {
let mut out: Vec<[i64; 6]> = s.world_state().bodies.iter().map(|b| key(solidcraft_kernel::measure(&b.body).unwrap().bbox)).collect();
out.sort();
out
}
fn components(s: &mut Session) -> Vec<Value> {
run(s, "component.list", json!({}))["components"].as_array().unwrap().clone()
}
fn component<'a>(list: &'a [Value], name: &str) -> &'a Value {
list.iter().find(|c| c["name"] == name).unwrap_or_else(|| panic!("no component {name}: {list:?}"))
}
fn occurrence_named(s: &Session, name: &str) -> u64 {
s.doc.occurrences.iter().find(|o| o.name == name).unwrap_or_else(|| panic!("no occurrence {name}")).id
}
/// The world corner of the bodies of one occurrence (by name) nearest -∞.
fn corner_of(s: &Session, occurrence: &str) -> Vec3 {
let occ = occurrence_named(s, occurrence);
let path = {
let mut path = vec![occ];
let mut parent = s.doc.occurrences.iter().find(|o| o.id == occ).unwrap().parent;
while parent != 0 {
let o = s.doc.occurrence_of(parent).unwrap();
path.insert(0, o.id);
parent = o.parent;
}
path
};
let names = s.occurrence_bodies(&path, true).unwrap();
assert!(!names.is_empty());
let world = s.world_state();
let mut lo = Vec3::new(f64::MAX, f64::MAX, f64::MAX);
for b in world.bodies.iter().filter(|b| names.contains(&b.name)) {
let bb = solidcraft_kernel::measure(&b.body).unwrap().bbox;
lo = Vec3::new(lo.x.min(bb.min.x), lo.y.min(bb.min.y), lo.z.min(bb.min.z));
}
lo
}
fn close(a: Vec3, b: Vec3) -> bool {
a.dist(b) < 1e-6
}
/// A box exported as `name.step` in `dir`.
fn part_file(dir: &std::path::Path, name: &str) -> String {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 30, "width": 20, "height": 10}));
let path = dir.join(format!("{name}.step")).to_string_lossy().to_string();
run(&mut s, "file.export", json!({"path": path}));
path
}
#[test]
fn a_step_part_comes_in_as_one_component_named_after_the_file() {
let dir = temp_dir("part");
let path = part_file(&dir, "bracket");
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [100, 0, 0]}));
let r = run(&mut s, "file.insert_step", json!({"path": path}));
let list = components(&mut s);
assert_eq!(list.len(), 2, "the design and the import: {list:?}");
let bracket = component(&list, "bracket");
assert_eq!((r["component"].clone(), r["component_name"].clone()), (bracket["id"].clone(), json!("bracket")), "{r}");
assert_eq!(bracket["bodies"].as_array().unwrap().len(), 1, "{bracket}");
assert_eq!(component(&list, &s.doc.name.clone())["bodies"].as_array().unwrap().len(), 1, "my own box stays where it was");
let feature = r["feature"].as_u64().unwrap();
assert_eq!(s.doc.feature(feature).unwrap().component, r["component"].as_u64().unwrap());
// One part: nothing to hold together.
assert!(s.doc.assembly.joints.is_empty() && r.get("subcomponents").is_none(), "{r}");
// The active component is still the one I was working in.
assert_eq!(s.active_component, 0);
// Opening the file as a design makes the component too, so the browser shows its name.
let r = run(&mut s, "doc.open", json!({"path": path}));
assert_eq!(r["component_name"], "bracket", "{r}");
assert_eq!(s.doc.components.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(), ["bracket"]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_step_assembly_comes_in_as_nested_components_with_its_instances() {
let dir = temp_dir("assembly");
let path = rig_file(&dir);
let mut s = Session::default();
let r = run(&mut s, "file.insert_step", json!({"path": path}));
// rig ⊃ Base, Arm (twice; Beam, Pin inside), Pin (once more, directly).
let list = components(&mut s);
let names: Vec<&str> = list.iter().filter_map(|c| c["name"].as_str()).collect();
assert_eq!(names.len(), 6, "{names:?}");
for n in ["rig", "Base", "Arm", "Beam", "Pin"] {
assert!(names.contains(&n), "{n} in {names:?}");
}
let id = |n: &str| component(&list, n)["id"].as_u64().unwrap();
let parent = |n: &str| component(&list, n)["parent"].as_u64().unwrap();
assert_eq!(parent("rig"), 0);
assert_eq!((parent("Base"), parent("Arm")), (id("rig"), id("rig")));
assert_eq!((parent("Beam"), parent("Pin")), (id("Arm"), id("Arm")));
assert_eq!(r["component"], id("rig"), "{r}");
assert_eq!(r["subcomponents"], 4, "{r}");
// A repeated part is one component placed again: Arm twice, Pin in the arm and on its own.
let placements = |c: u64| s.doc.occurrences.iter().filter(|o| o.component == c).count();
assert_eq!((placements(id("Arm")), placements(id("Pin")), placements(id("Beam"))), (2, 2, 1));
// Each component holds its own part's body; the file's later instances are not built again.
let own = |n: &str| component(&list, n)["bodies"].as_array().unwrap().len();
assert_eq!((own("rig"), own("Base"), own("Arm"), own("Beam"), own("Pin")), (0, 1, 0, 1, 1));
assert_eq!(s.model.state().bodies.len(), 3, "base, beam and pin, once each");
// And every body shows where the file puts it.
assert_eq!(shown_boxes(&s), file_boxes(&path));
// Saved and opened again, it is the same.
let design = dir.join("rig.solidcraft").to_string_lossy().to_string();
run(&mut s, "file.save_as", json!({"path": design}));
let mut again = Session::default();
run(&mut again, "doc.open", json!({"path": design}));
assert_eq!(shown_boxes(&again), file_boxes(&path));
assert_eq!(again.doc.components.len(), 5);
let _ = std::fs::remove_dir_all(&dir);
}
/// Each occurrence's degrees of freedom, as `joint.list` gives them.
fn dofs(s: &mut Session) -> (std::collections::BTreeMap<u64, u64>, Value) {
let r = run(s, "joint.list", json!({}));
let dof = r["dof"].as_array().unwrap().iter().map(|d| (d["occurrence"].as_u64().unwrap(), d["dof"].as_u64().unwrap())).collect();
(dof, r)
}
#[test]
fn an_imported_assembly_moves_as_one_until_it_is_unlocked() {
let dir = temp_dir("rigid");
let path = rig_file(&dir);
let mut s = Session::default();
run(&mut s, "file.insert_step", json!({"path": path}));
let rig = occurrence_named(&s, "rig:1");
let inside: Vec<u64> = s.doc.occurrences.iter().filter(|o| o.id != rig).map(|o| o.id).collect();
assert_eq!(inside.len(), 6);
let pin = corner_of(&s, "Pin:2");
let arm = corner_of(&s, "Arm:1");
// The whole import is one rigid body: no part of it moves against the others.
let (dof, r) = dofs(&mut s);
assert!(r["conflicts"].as_array().unwrap().is_empty(), "{r}");
for o in &inside {
assert_eq!(dof.get(o), dof.get(&rig), "occurrence {o} moves only with the import: {r}");
}
// Moving the import's component moves everything in it.
run(&mut s, "occurrence.move", json!({"occurrence": "rig:1", "translate": [0, 0, 50]}));
assert!(close(corner_of(&s, "Pin:2"), pin + Vec3::new(0.0, 0.0, 50.0)), "{:?}", corner_of(&s, "Pin:2"));
assert!(close(corner_of(&s, "Arm:1"), arm + Vec3::new(0.0, 0.0, 50.0)));
// A part inside doesn't move on its own; the refusal says what to move instead.
for part in ["Pin:2", "Pin:1"] {
let e = s.execute("occurrence.move", &json!({"occurrence": part, "translate": [30, 0, 0]})).unwrap_err().to_string();
assert!(e.contains("move rig"), "{e}");
}
assert!(close(corner_of(&s, "Pin:2"), pin + Vec3::new(0.0, 0.0, 50.0)));
// Next to a grounded part of mine the import is still one rigid body.
run(&mut s, "component.create", json!({"name": "Mine"}));
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [-50, 0, 0]}));
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "occurrence.ground", json!({"occurrence": "Mine:1", "grounded": true}));
let (dof, r) = dofs(&mut s);
for o in &inside {
assert_eq!(dof.get(o), dof.get(&rig), "{r}");
}
assert!(r["conflicts"].as_array().unwrap().is_empty(), "{r}");
// Unlocked (one undo step), the parts move one by one.
let undo = s.undo.len();
let r = run(&mut s, "component.unlock", json!({"component": "rig"}));
assert_eq!(r["joints"].as_array().unwrap().len(), 6, "{r}");
assert_eq!(s.undo.len(), undo + 1);
run(&mut s, "occurrence.move", json!({"occurrence": "Pin:2", "translate": [30, 0, 0]}));
assert!(close(corner_of(&s, "Pin:2"), pin + Vec3::new(30.0, 0.0, 50.0)), "{:?}", corner_of(&s, "Pin:2"));
assert!(close(corner_of(&s, "Arm:1"), arm + Vec3::new(0.0, 0.0, 50.0)), "the others stay");
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.assembly.joints.len(), 6, "undo locks it again");
assert!(s.execute("component.unlock", &json!({"component": "Mine"})).is_err(), "nothing holds my own component");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_mesh_file_comes_in_as_one_component() {
let dir = temp_dir("stl");
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
let stl = dir.join("cube.stl").to_string_lossy().to_string();
run(&mut s, "file.save_mesh", json!({"path": stl}));
let mut s = Session::default();
let r = run(&mut s, "file.insert_mesh", json!({"path": stl}));
assert_eq!(r["component_name"], "cube", "{r}");
let list = components(&mut s);
assert_eq!(list.len(), 2);
assert_eq!(component(&list, "cube")["bodies"].as_array().unwrap().len(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn undo_takes_the_whole_import_away() {
let dir = temp_dir("undo");
let path = rig_file(&dir);
let mut s = Session::default();
let undo = s.undo.len();
run(&mut s, "file.insert_step", json!({"path": path}));
assert_eq!(s.undo.len(), undo + 1, "one step");
run(&mut s, "edit.undo", json!({}));
assert!(s.doc.features.is_empty() && s.doc.components.is_empty() && s.doc.occurrences.is_empty(), "nothing left");
assert!(s.doc.assembly.joints.is_empty() && s.doc.body_components.is_empty() && s.doc.body_offsets.is_empty());
assert!(s.world_state().bodies.is_empty());
run(&mut s, "edit.redo", json!({}));
assert_eq!(shown_boxes(&s), file_boxes(&path));
let _ = std::fs::remove_dir_all(&dir);
}
/// A design saved before imports made components has its Import loose in the design: it opens
/// as it is (no component is made for it) and builds every body of the file where it was.
#[test]
fn a_design_with_a_loose_import_rebuilds_unchanged() {
let dir = temp_dir("loose");
let path = rig_file(&dir);
let sf = solidcraft_io::step_import_feature(&std::fs::read(&path).unwrap(), &path).unwrap();
let mut doc = solidcraft_doc::Document::new("Old");
let id = doc.add_feature(sf.kind, Some(&sf.name)).unwrap();
doc.feature_mut(id).unwrap().body_names = sf.body_names;
// As a format 21 file (0.12.0) has it.
let mut json: Value = serde_json::from_str(&doc.to_json()).unwrap();
json["format"] = json!("solidcraft/21");
assert!(json["features"][0]["kind"].get("skip").is_none());
let file = dir.join("old.solidcraft");
std::fs::write(&file, json.to_string()).unwrap();
let mut s = Session::default();
run(&mut s, "doc.open", json!({"path": file.to_string_lossy()}));
assert!(s.doc.components.is_empty() && s.doc.occurrences.is_empty(), "not migrated");
assert_eq!(s.doc.features[0].component, 0);
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
assert_eq!(names, ["Base", "Beam", "Pin", "Beam (2)", "Pin (2)", "Pin (3)"]);
assert_eq!(shown_boxes(&s), file_boxes(&path));
let _ = std::fs::remove_dir_all(&dir);
}
+14 -12
View File
@@ -90,9 +90,10 @@ fn a_y_up_step_file_comes_in_standing_up() {
let r = run(&mut d, "file.insert_step", json!({"path": path, "up": "y"}));
let (lo, hi) = extent(&d);
assert!(near(lo, [0.0, -20.0, 0.0]) && near(hi, [40.0, 0.0, 34.0]), "stood up: {lo:?} {hi:?}");
assert_eq!(kinds(&d), ["BaseFeature", "MoveFeature"]);
assert_eq!(r["stand_up"], d.doc.features[1].id, "{r}");
assert_eq!(r["created_bodies"].as_array().unwrap().len(), 3, "{r}");
// The import's component is turned (#1386), so an assembly's instances turn with it.
assert_eq!(kinds(&d), ["BaseFeature"]);
assert_eq!(r["stand_up"], r["occurrence"], "{r}");
assert_eq!(r["created_bodies"].as_array().unwrap().len(), 2, "the plate, and the post placed twice: {r}");
assert!((total_volume(&mut d) - (40.0 * 4.0 * 20.0 + 2.0 * 4.0 * 30.0 * 4.0)).abs() < 1e-6);
// The Import and the turn are one undo step.
assert_eq!(d.undo.len(), undo + 1);
@@ -106,7 +107,7 @@ fn a_y_up_step_file_comes_in_standing_up() {
let (lo, hi) = extent(&d);
assert!(near(lo, [0.0, -20.0, 0.0]) && near(hi, [105.0, 106.0, 107.0]), "{lo:?} {hi:?}");
// The turn is an ordinary Move feature: the saved design has it and needs no new field.
// The turn is the component's placement: the saved design has it.
let design = std::path::Path::new(&path).with_file_name("frame.solidcraft");
run(&mut s, "edit.undo", json!({}));
run(&mut s, "file.insert_step", json!({"path": path, "up": "y"}));
@@ -122,7 +123,7 @@ fn opening_a_y_up_step_file_can_stand_it_up() {
let path = frame_file("open");
let mut s = Session::default();
let r = run(&mut s, "doc.open", json!({"path": path, "up": "y"}));
assert_eq!(r["features"], 2, "{r}");
assert_eq!(r["features"], 1, "{r}");
assert!(near(extent(&s).1, [40.0, 0.0, 34.0]));
assert!(s.undo.is_empty(), "a freshly opened design has no history");
run(&mut s, "doc.open", json!({"path": path}));
@@ -181,22 +182,23 @@ fn the_bodies_are_named_after_the_solid_the_product_or_the_instance() {
assert_eq!(r["name"], "Frame", "the timeline entry is the root product's");
body_names(&s)
};
let solids = ["Cut-Extrude3", "Boss-Extrude1", "Boss-Extrude1 (2)"];
// The post is placed twice: one component, two occurrences, its body built once (#1386).
let solids = ["Cut-Extrude3", "Boss-Extrude1"];
assert_eq!(named(json!({"path": path})), solids);
assert_eq!(named(json!({"path": path, "names": "solid"})), solids);
assert_eq!(named(json!({"path": path, "names": "product"})), ["Plate", "Post", "Post (2)"]);
assert_eq!(named(json!({"path": path, "names": "Instance"})), ["Plate-1", "Post-1", "Post-2"]);
assert_eq!(named(json!({"path": path, "names": "product"})), ["Plate", "Post"]);
assert_eq!(named(json!({"path": path, "names": "Instance"})), ["Plate-1", "Post-1"]);
// The names are stored with the import: a saved design keeps them, and `up` does not change them.
let mut s = Session::default();
run(&mut s, "doc.open", json!({"path": path, "names": "instance", "up": "y"}));
assert_eq!(body_names(&s), ["Plate-1", "Post-1", "Post-2"]);
assert_eq!(body_names(&s), ["Plate-1", "Post-1"]);
let design = std::path::Path::new(&path).with_file_name("frame-named.solidcraft");
run(&mut s, "file.save_as", json!({"path": design.to_string_lossy()}));
let mut again = Session::default();
run(&mut again, "doc.open", json!({"path": design.to_string_lossy()}));
assert_eq!(body_names(&again), ["Plate-1", "Post-1", "Post-2"]);
assert_eq!(body_names(&again), ["Plate-1", "Post-1"]);
// A feature finds them by those names.
run(&mut again, "solid.move", json!({"bodies": ["Post-2"], "translate": [0, 0, 5]}));
assert_eq!(body_names(&again), ["Plate-1", "Post-1", "Post-2"]);
run(&mut again, "solid.move", json!({"bodies": ["Post-1"], "translate": [0, 0, 5]}));
assert_eq!(body_names(&again), ["Plate-1", "Post-1"]);
}