Files
OneCAD/crates/engine/src/tests/import_component.rs
T
bherbruckandClaude Opus 5.5 27529d9742 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 <[email protected]>

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Catalog entries for Unlock Contents

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-15 11:04:49 -04:00

325 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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);
}