From 27529d9742d272340f9845539c80c351938f560c Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 15 Oct 2026 11:04:49 -0400 Subject: [PATCH] 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 * Keep an import's rigid group hidden when the window loads the file in the background Co-Authored-By: Claude Opus 5.5 * Import instances' skip list is format 22: format 21 ships in 0.12.0 Co-Authored-By: Claude Opus 5.5 * Catalog entries for Unlock Contents Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- assets/locales/de.json | 1 + assets/locales/en.json | 1 + assets/locales/fr.json | 1 + assets/locales/ja.json | 1 + assets/locales/ko.json | 1 + assets/locales/ru.json | 1 + assets/locales/uk.json | 1 + assets/locales/zh.json | 1 + crates/doc/src/document.rs | 5 + crates/doc/src/eval.rs | 32 +- crates/doc/src/format.rs | 13 +- crates/doc/src/joints.rs | 11 +- crates/doc/src/lib.rs | 6 +- crates/engine/src/cmd/clipboard.rs | 7 +- crates/engine/src/cmd/component.rs | 32 ++ crates/engine/src/cmd/component_clone.rs | 2 +- .../src/cmd/config_certificate_tests.rs | 2 +- crates/engine/src/cmd/file.rs | 96 ++--- crates/engine/src/cmd/file_import.rs | 380 ++++++++++++++++++ crates/engine/src/lib.rs | 6 + crates/engine/src/product_scene.rs | 10 +- crates/engine/src/tests.rs | 5 +- crates/engine/src/tests/import_component.rs | 324 +++++++++++++++ .../engine/src/tests/step_import_options.rs | 26 +- crates/io/src/design.rs | 16 +- crates/io/src/lib.rs | 2 +- crates/mcp/src/tests.rs | 2 +- crates/mcp/src/tools.rs | 6 +- crates/ui-egui/src/context_menu.rs | 13 +- crates/ui-egui/src/file_load.rs | 2 +- crates/ui-egui/src/lib.rs | 9 +- crates/ui-egui/src/stand_up.rs | 23 +- .../ui-egui/src/viewport_appearance_tests.rs | 5 +- docs/book/src/guide/files.md | 7 + docs/design-container.md | 2 +- docs/features.md | 5 +- docs/step-import.md | 14 +- 37 files changed, 968 insertions(+), 103 deletions(-) create mode 100644 crates/engine/src/cmd/file_import.rs create mode 100644 crates/engine/src/tests/import_component.rs diff --git a/assets/locales/de.json b/assets/locales/de.json index 8f3cb0e8..2de4f56b 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -483,6 +483,7 @@ "cmd.component_capture_position": "Position erfassen", "cmd.component_create": "Neue Komponente", "cmd.component_delete": "Komponente löschen", + "cmd.component_unlock": "Inhalt entsperren", "cmd.component_from_bodies": "Komponenten aus Körpern erstellen", "cmd.component_insert_design": "Konstruktion einfügen", "cmd.component_links": "Verknüpfungen auflisten", diff --git a/assets/locales/en.json b/assets/locales/en.json index 6cce4388..f2171842 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -36,6 +36,7 @@ "cmd.component_capture_position": "Capture Position", "cmd.component_create": "New Component", "cmd.component_delete": "Delete Component", + "cmd.component_unlock": "Unlock Contents", "cmd.component_from_bodies": "Create Components from Bodies", "cmd.component_insert_design": "Insert Design", "cmd.component_links": "List Links", diff --git a/assets/locales/fr.json b/assets/locales/fr.json index 90b9a726..2c6fcad3 100644 --- a/assets/locales/fr.json +++ b/assets/locales/fr.json @@ -36,6 +36,7 @@ "cmd.component_capture_position": "Capturer la position", "cmd.component_create": "Nouveau composant", "cmd.component_delete": "Supprimer le composant", + "cmd.component_unlock": "Déverrouiller le contenu", "cmd.component_from_bodies": "Créer des composants à partir des corps", "cmd.component_insert_design": "Insérer une conception", "cmd.component_links": "Lister les liens", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index 5c17756f..de09e9ab 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -36,6 +36,7 @@ "cmd.component_capture_position": "位置をキャプチャ", "cmd.component_create": "新規コンポーネント", "cmd.component_delete": "コンポーネントを削除", + "cmd.component_unlock": "内容のロックを解除", "cmd.component_from_bodies": "ボディからコンポーネントを作成", "cmd.component_insert_design": "デザインを挿入", "cmd.component_links": "リンクを一覧表示", diff --git a/assets/locales/ko.json b/assets/locales/ko.json index 02c70ea7..8c75e363 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -483,6 +483,7 @@ "cmd.component_capture_position": "위치 캡처", "cmd.component_create": "새 구성 요소", "cmd.component_delete": "구성 요소 삭제", + "cmd.component_unlock": "내용 잠금 해제", "cmd.component_from_bodies": "바디로 구성 요소 만들기", "cmd.component_insert_design": "설계 삽입", "cmd.component_links": "링크 목록", diff --git a/assets/locales/ru.json b/assets/locales/ru.json index 9736c97f..46a89c8a 100644 --- a/assets/locales/ru.json +++ b/assets/locales/ru.json @@ -25,6 +25,7 @@ "cmd.component_capture_position": "Зафиксировать положение", "cmd.component_create": "Новый компонент", "cmd.component_delete": "Удалить компонент", + "cmd.component_unlock": "Разблокировать содержимое", "cmd.component_from_bodies": "Создать компоненты из тел", "cmd.component_insert_design": "Вставить дизайн", "cmd.component_links": "Список связей", diff --git a/assets/locales/uk.json b/assets/locales/uk.json index 8e799cf0..da37bd53 100644 --- a/assets/locales/uk.json +++ b/assets/locales/uk.json @@ -30,6 +30,7 @@ "cmd.component_capture_position": "Зафіксувати положення", "cmd.component_create": "Новий компонент", "cmd.component_delete": "Видалити компонент", + "cmd.component_unlock": "Розблокувати вміст", "cmd.component_from_bodies": "Створити компоненти з тіл", "cmd.component_insert_design": "Вставити проєкт", "cmd.component_links": "Список зв'язків", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index c52b5231..39acf92a 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -36,6 +36,7 @@ "cmd.component_capture_position": "捕获位置", "cmd.component_create": "新建组件", "cmd.component_delete": "删除组件", + "cmd.component_unlock": "解锁内容", "cmd.component_from_bodies": "由实体创建组件", "cmd.component_insert_design": "插入设计", "cmd.component_links": "列出链接", diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 2cf094af..d8965f2c 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -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, + /// 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, }, /// Rebuild an upstream constant-section mesh with analytic planar/cylindrical faces. MeshConvert { diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 516f205d..f9b5bbf4 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -4681,15 +4681,12 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, 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> { 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> { + let skip: std::collections::HashSet = 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, diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs index a8bcddc4..0f947449 100644 --- a/crates/doc/src/format.rs +++ b/crates/doc/src/format.rs @@ -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 { /// 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 diff --git a/crates/doc/src/joints.rs b/crates/doc/src/joints.rs index 8bc4703f..50225442 100644 --- a/crates/doc/src/joints.rs +++ b/crates/doc/src/joints.rs @@ -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 = BTreeSet::new(); let mut queue: VecDeque = 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> = 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; } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 1aa47845..4d8b705b 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -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; diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index 00df4c06..55c4232d 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -374,9 +374,14 @@ mod tests { let second = run(&mut s, "file.insert_step", json!({"path": path.to_string_lossy()}))["feature"].clone(); assert_eq!(body_names(&s), ["Aa", "Bb", "Cc", "Aa (2)", "Bb (2)", "Cc (2)"]); paste_import_with_two_lifts(&mut s, &second, ["Aa (2)", "Bb (2)", "Cc (2)"]); + // Each import is a component of its own (#1386), shown after the pasted copies: the first + // import's bodies stay where they were, the second's and their copies are lifted. let plain = [[0.0, 0.0, 0.0], [30.0, 0.0, 0.0], [60.0, 0.0, 0.0]]; - let at: Vec> = plain.iter().chain(&LIFTED).chain(&LIFTED).map(|c| c.to_vec()).collect(); + let at: Vec> = LIFTED.iter().chain(&plain).chain(&LIFTED).map(|c| c.to_vec()).collect(); assert_eq!(corners(&mut s), at, "{:?}", body_names(&s)); + let m = run(&mut s, "inspect.measure", json!({})); + let names: Vec<&str> = m["bodies"].as_array().unwrap().iter().filter_map(|b| b["name"].as_str()).collect(); + assert_eq!(names, ["Aa (copy)", "Bb (copy)", "Cc (copy)", "Aa", "Bb", "Cc", "Aa (2)", "Bb (2)", "Cc (2)"]); } /// A cylinder Emboss finds its face by the persistent name it recorded when it was made. The diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index a4869631..ffe5510f 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -42,6 +42,10 @@ pub static COMMANDS: &[CommandSpec] = &[ .params("occurrence: id or name; path?: [occurrence ids] (required if nested under multiple instances); descendants?: bool (default true). Physical properties of this placement in world coordinates, including pending moves"), CommandSpec::new("component.rename", "Rename Component", rename).params("component, name"), CommandSpec::new("component.delete", "Delete Component", delete).params("component: deletes it, its subcomponents, occurrences and features"), + CommandSpec::new("component.unlock", "Unlock Contents", unlock).icon("rigid_group").params( + "component: removes the rigid joints that hold its contents together (an imported assembly's rigid group), so its parts \ + move and take joints one by one → joints (the ids removed)", + ), CommandSpec::new("component.move_bodies", "Move Bodies to Component", move_bodies).params("bodies: [names], component"), CommandSpec::new("component.move_sketches", "Move Sketches to Component", move_sketches).params("sketches: [ids or names], component"), CommandSpec::new("occurrence.copy", "Paste (Instance)", copy_occurrence) @@ -251,6 +255,23 @@ fn delete(s: &mut Session, p: &Value) -> Result { Ok(json!({"deleted": gone, "features": feats})) } +fn unlock(s: &mut Session, p: &Value) -> Result { + let cmd = "component.unlock"; + let id = component(s, p, "component", cmd)?; + if id == 0 { + return Err(bad(cmd, "pick a component, not the design")); + } + let gone = s.contents_joints(id); + if gone.is_empty() { + return Err(bad(cmd, "nothing holds this component's contents together")); + } + let doc = s.doc_mut(); + doc.assembly.joints.retain(|j| !gone.contains(&j.id)); + doc.assembly.links.retain(|l| !gone.contains(&l.a) && !gone.contains(&l.b)); + s.visibility.hidden_joints.retain(|h| !gone.contains(h)); + Ok(json!({"component": id, "joints": gone})) +} + fn move_bodies(s: &mut Session, p: &Value) -> Result { transfer::move_bodies(s, p) } @@ -376,6 +397,17 @@ fn move_occurrence(s: &mut Session, p: &Value) -> Result { if o.grounded { return Err(bad(cmd, format!("{} is grounded", o.name))); } + if let Some(mut group) = s.held_by_rigid_group(id) { + // The outermost component the group holds it in moves it. + for _ in 0..64 { + match s.doc.occurrence_of(group).and_then(|o| s.held_by_rigid_group(o.id)) { + Some(up) => group = up, + None => break, + } + } + let name = s.doc.components.iter().find(|c| c.id == group).map(|c| c.name.clone()).unwrap_or_default(); + return Err(bad(cmd, format!("{} is held in {name} by a rigid group: move {name}, or Unlock Contents (component.unlock) first", o.name))); + } let t = move_vector(p, "translate", Vec3::ZERO, true, cmd)?; let axis = move_vector(p, "axis", Vec3::Z, false, cmd)?; let angle = match p.get("angle") { diff --git a/crates/engine/src/cmd/component_clone.rs b/crates/engine/src/cmd/component_clone.rs index 26ca9b7a..bf03c14d 100644 --- a/crates/engine/src/cmd/component_clone.rs +++ b/crates/engine/src/cmd/component_clone.rs @@ -346,7 +346,7 @@ pub(super) fn paste( bodies.insert(body.name.clone(), new_name.clone()); let feature_name = unique(&format!("{} Base", body.name), &mut used); let id = scratch.doc_mut().add_feature( - FeatureKind::Import { file: format!("{} independent base", body.name), step: step.into(), components: Vec::new() }, + FeatureKind::Import { file: format!("{} independent base", body.name), step: step.into(), components: Vec::new(), skip: Vec::new() }, Some(&feature_name), )?; if let Some(f) = scratch.doc_mut().feature_mut(id) { diff --git a/crates/engine/src/cmd/config_certificate_tests.rs b/crates/engine/src/cmd/config_certificate_tests.rs index 75f850a3..1415d8db 100644 --- a/crates/engine/src/cmd/config_certificate_tests.rs +++ b/crates/engine/src/cmd/config_certificate_tests.rs @@ -73,7 +73,7 @@ fn imported(session: &mut Session, step: String, name: &str) -> u64 { let owner = session.active_component; let id = session .doc_mut() - .add_feature(FeatureKind::Import { file: "fixture.step".into(), step: step.into(), components: vec![] }, Some(name)) + .add_feature(FeatureKind::Import { file: "fixture.step".into(), step: step.into(), components: vec![], skip: Vec::new() }, Some(name)) .unwrap(); let feature = session.doc_mut().feature_mut(id).unwrap(); feature.body_names = vec![name.into()]; diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index f873b27b..1fdf684a 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -3,6 +3,9 @@ #[path = "file_thumbnail.rs"] mod thumbnail; +#[path = "file_import.rs"] +pub(crate) mod import; + use serde_json::{Value, json}; use solidcraft_doc::Document; use solidcraft_io::Format; @@ -17,15 +20,15 @@ pub static COMMANDS: &[CommandSpec] = &[ .icon("open") .key("Ctrl+O") .noundo() - .params("path: .solidcraft design, or a .step/.stp file (opens as a new design); up?: z|y and names?: solid|product|instance for a STEP or IGES file (see file.insert_step)"), + .params("path: .solidcraft design, or a .step/.stp/.igs/.3mf/.stl/.obj file (opens as a new design holding the file as a component named after it, as file.insert_step makes); up?: z|y and names?: solid|product|instance for a STEP or IGES file (see file.insert_step)"), CommandSpec::new("file.insert_step", "Insert STEP", insert_step) .at("SOLID", "INSERT") .icon("import") - .params("path: .step/.stp file (its bodies join the design as an Import base feature); name?; up?: z|y (default z: the file's axes are kept; y: the file has Y up, so its bodies are turned a quarter turn about X, Y becoming Z, by a Move feature after the import); names?: solid|product|instance (what the bodies are named after: the solid's own name, else the product's (default); the name of the product (part) that holds the body; the name of the assembly occurrence (instance), else the product's)"), + .params("path: .step/.stp file (its bodies join the design as an Import base feature in a new component named after the file, placed in the active component; an assembly's products become nested components, a repeated part one component with an occurrence per placement, all held as one rigid group until component.unlock → component, component_name, occurrence, subcomponents?, rigid_joints?); name? (the feature's and the component's); up?: z|y (default z: the file's axes are kept; y: the file has Y up, so the import's component is turned a quarter turn about X, Y becoming Z → stand_up: its occurrence); names?: solid|product|instance (what the bodies are named after: the solid's own name, else the product's (default); the name of the product (part) that holds the body; the name of the assembly occurrence (instance), else the product's)"), CommandSpec::new("file.insert_mesh", "Insert Mesh", insert_mesh) .at("SOLID", "INSERT") .icon("import") - .params("path: .3mf or .stl file (its meshes join the design as mesh bodies); name?"), + .params("path: .3mf, .stl or .obj file (its meshes join the design as mesh bodies in a new component named after the file → component, component_name, occurrence); name?"), CommandSpec::new("file.save", "Save", save) .icon("save") .key("Ctrl+S") @@ -99,65 +102,57 @@ fn read_step(path: &str, names: solidcraft_io::StepNames) -> Result Result { - let kind = solidcraft_doc::FeatureKind::Move { - bodies: bodies.to_vec(), - translate: ["0".into(), "0".into(), "0".into()], - rotate_axis: Some(solidcraft_geom::Vec3::X), - angle: Some("90 deg".into()), - copy: false, - }; - let id = s.doc_mut().add_feature(kind, None)?; - s.refresh(); - match s.model.result(id).and_then(|r| r.error.clone()) { - Some(e) => Err(EngineError::Other(e)), - None => Ok(id), - } +/// A quarter turn about X through the origin: a file's Y up becomes Z up. +fn quarter_turn_x() -> solidcraft_doc::Mat { + solidcraft_doc::rigid(solidcraft_geom::Vec3::ZERO, solidcraft_geom::Vec3::ZERO, solidcraft_geom::Vec3::X, std::f64::consts::FRAC_PI_2) } -/// Add an Import feature for a STEP file to the session's design. +/// Add an Import feature for a STEP file to the session's design, in a new component named +/// after the file (#1386). fn add_step(s: &mut Session, path: &str, name: Option<&str>, options: &StepOptions) -> Result { let sf = read_step(path, options.names)?; let fname = name.filter(|n| !n.trim().is_empty()).unwrap_or(&sf.name).to_string(); - let id = s.doc_mut().add_feature(sf.kind, Some(&fname))?; - if let Some(f) = s.doc_mut().feature_mut(id) { - f.body_names = sf.body_names; - } - s.active_sketch = None; - s.refresh(); - if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) { - return Err(EngineError::Other(e)); - } - let created: Vec = s.model.state().bodies.iter().filter(|b| b.feature == id).map(|b| b.name.clone()).collect(); - let turned = if options.y_up && !created.is_empty() { Some(stand_up(s, &created)?) } else { None }; + let cname = import::component_name(path, name); + let (id, placed) = import::add_in_component(s, sf.kind, &fname, sf.body_names, &cname, Some(&sf.import))?; + // Y up: the import's component is turned a quarter turn about X (Y becomes Z), so an + // assembly's parts and instances turn together. + let turned = if options.y_up { + let occ = placed.occurrence.ok_or_else(|| EngineError::Other("the import has no placement to turn".into()))?; + if let Some(o) = s.doc_mut().occurrences.iter_mut().find(|o| o.id == occ) { + o.transform = solidcraft_doc::mat_mul(&quarter_turn_x(), &o.transform); + } + s.refresh(); + Some(occ) + } else { + None + }; + let created = created_bodies(s, id); let fname = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default(); let mut out = json!({"path": path, "feature": id, "name": fname, "bodies": created, "created_bodies": created, "warnings": sf.warnings}); + placed.report(&mut out); if let Some(turned) = turned { out["stand_up"] = json!(turned); } Ok(out) } -/// Add a MeshImport feature for a 3MF or STL file. +/// The bodies an import feature built, by name. +fn created_bodies(s: &Session, feature: u64) -> Vec { + s.model.state().bodies.iter().filter(|b| b.feature == feature).map(|b| b.name.clone()).collect() +} + +/// Add a MeshImport feature for a 3MF, STL or OBJ file, in a new component named after the file. fn add_mesh(s: &mut Session, path: &str, name: Option<&str>) -> Result { let bytes = solidcraft_io::vfs::read(path, solidcraft_io::MAX_3MF_BYTES).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; let mf = solidcraft_io::mesh_import_feature(&bytes, path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; let fname = name.filter(|n| !n.trim().is_empty()).unwrap_or(&mf.name).to_string(); - let id = s.doc_mut().add_feature(mf.kind, Some(&fname))?; - if let Some(f) = s.doc_mut().feature_mut(id) { - f.body_names = mf.body_names; - } - s.active_sketch = None; - s.refresh(); - if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) { - return Err(EngineError::Other(e)); - } - let st = s.model.state(); - let bodies: Vec<&str> = st.bodies.iter().filter(|b| b.feature == id).map(|b| b.name.as_str()).collect(); + let cname = import::component_name(path, name); + let (id, placed) = import::add_in_component(s, mf.kind, &fname, mf.body_names, &cname, None)?; + let bodies = created_bodies(s, id); let fname = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default(); - Ok(json!({"path": path, "feature": id, "name": fname, "bodies": bodies, "created_bodies": bodies, "warnings": mf.warnings})) + let mut out = json!({"path": path, "feature": id, "name": fname, "bodies": bodies, "created_bodies": bodies, "warnings": mf.warnings}); + placed.report(&mut out); + Ok(out) } fn insert_mesh(s: &mut Session, p: &Value) -> Result { @@ -193,10 +188,21 @@ fn open(s: &mut Session, p: &Value) -> Result { } else { add_mesh(&mut fresh, path, None)? }; + // The design is the file: its component is the one being worked in. + if let Some(c) = r["component"].as_u64() { + fresh.active_component = c; + fresh.active_occurrence_path = Some(fresh.doc.first_path(c)); + } fresh.undo.clear(); s.replace_design(fresh); let errors = s.model.results.iter().filter(|r| r.error.is_some()).count(); - return Ok(json!({"path": path, "features": s.doc.features.len(), "errors": errors, "bodies": r["bodies"], "warnings": r["warnings"]})); + let mut out = json!({"path": path, "features": s.doc.features.len(), "errors": errors, "bodies": r["bodies"], "warnings": r["warnings"]}); + for k in ["component", "component_name", "occurrence", "subcomponents", "rigid_joints", "stand_up"] { + if let Some(v) = r.get(k) { + out[k] = v.clone(); + } + } + return Ok(out); } let mut doc = solidcraft_io::read_design_file(path, s.open_preview.take()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; if let Some(stem) = default_name_for(&doc.name, path) { diff --git a/crates/engine/src/cmd/file_import.rs b/crates/engine/src/cmd/file_import.rs new file mode 100644 index 00000000..ee016fa9 --- /dev/null +++ b/crates/engine/src/cmd/file_import.rs @@ -0,0 +1,380 @@ +//! Where an imported file's bodies go (#1386). Every import (STEP, IGES, 3MF, STL, OBJ) becomes a +//! new component named after the file, placed in the active component at the file's origin, and +//! its feature belongs to that component. A STEP assembly keeps its structure as components +//! nested under it, named after the file's products; a part (or sub-assembly) the file places +//! more than once becomes one component with an occurrence per placement, its later instances +//! not built again by the Import feature (`skip`). The contents of an imported assembly are held +//! together by rigid joints, so the import moves as one; `component.unlock` releases them. + +use std::collections::HashMap; +use std::hash::{Hash, Hasher}; + +use serde_json::{Value, json}; +use solidcraft_doc::joints::{Joint, JointKind, JointOrigin, Snap}; +use solidcraft_doc::kernel::{StepImport, StepNode}; +use solidcraft_doc::{FeatureKind, IDENTITY, Mat}; +use solidcraft_geom::Vec3; + +use crate::{EngineError, Result, Session}; + +/// Most components one import makes below its own (the rest of the file stays in the parent). +const MAX_COMPONENTS: usize = 5_000; +/// Deepest nesting followed (below the document's placement depth limit). +const MAX_DEPTH: usize = 32; + +/// A component the import makes below its own. +struct Slot { + name: String, +} + +/// A placement of a slot in another. +struct Place { + slot: usize, + parent: Option, + transform: Mat, +} + +/// How a STEP file's tree becomes components. +#[derive(Default)] +struct Plan { + slots: Vec, + places: Vec, + /// Body index → the slot it goes to, and the transform from that slot's frame to the import + /// component's (where the Import feature builds it). + bodies: Vec<(usize, usize, Mat)>, + /// Bodies not built: later instances. + skip: Vec, +} + +/// A placement STEP assemblies can carry over as an occurrence: finite, rigid, not mirrored. +fn rigid(m: &Mat) -> bool { + crate::cmd::component::valid_position(m) +} + +impl Plan { + fn of(imp: &StepImport) -> Plan { + let mut plan = Plan::default(); + // One root product is the import's own component; several are components under it. + let nodes: Vec<&StepNode> = match imp.tree.as_slice() { + [root] => root.children.iter().collect(), + roots => roots.iter().collect(), + }; + let mut seen: HashMap = HashMap::new(); + for n in nodes { + plan.walk(imp, n, None, &IDENTITY, &mut seen, 0); + } + plan.skip.sort_unstable(); + plan.skip.dedup(); + plan + } + + fn walk(&mut self, imp: &StepImport, node: &StepNode, parent: Option, parent_world: &Mat, seen: &mut HashMap, depth: usize) { + if !has_bodies(node, 0) { + return; + } + let world = solidcraft_doc::mat_mul(parent_world, &node.transform); + let inverse = solidcraft_doc::mat_inverse(&world); + // Too deep, too many, or placed in a way an occurrence can't be: the bodies stay where + // the parent is (they are built in place, so nothing moves). + let (Some(inverse), true, true, true) = (inverse, depth < MAX_DEPTH, self.slots.len() < MAX_COMPONENTS, subtree_rigid(node, depth)) else { + self.flatten(node, parent, parent_world); + return; + }; + let key = node_key(imp, node, 0); + if let Some(&slot) = seen.get(&key) { + // Another placement of a part already made: an occurrence of its component, and the + // file's copy of its bodies is not built. + self.places.push(Place { slot, parent, transform: node.transform }); + collect_bodies(node, &mut self.skip, 0); + return; + } + let slot = self.slots.len(); + self.slots.push(Slot { name: node.name.trim().to_string() }); + seen.insert(key, slot); + self.places.push(Place { slot, parent, transform: node.transform }); + for b in &node.bodies { + self.bodies.push((*b, slot, inverse)); + } + for c in &node.children { + self.walk(imp, c, Some(slot), &world, seen, depth + 1); + } + } + + /// Every body below `node` goes to `parent`. + fn flatten(&mut self, node: &StepNode, parent: Option, parent_world: &Mat) { + let Some(parent) = parent else { return }; + let Some(inverse) = solidcraft_doc::mat_inverse(parent_world) else { return }; + let mut all = Vec::new(); + collect_bodies(node, &mut all, 0); + self.bodies.extend(all.into_iter().map(|b| (b as usize, parent, inverse))); + } +} + +fn has_bodies(node: &StepNode, depth: usize) -> bool { + !node.bodies.is_empty() || (depth < 64 && node.children.iter().any(|c| has_bodies(c, depth + 1))) +} + +fn subtree_rigid(node: &StepNode, depth: usize) -> bool { + rigid(&node.transform) && (depth >= 64 || node.children.iter().all(|c| subtree_rigid(c, depth + 1))) +} + +fn collect_bodies(node: &StepNode, out: &mut Vec, depth: usize) { + out.extend(node.bodies.iter().map(|b| *b as u64)); + if depth < 64 { + for c in &node.children { + collect_bodies(c, out, depth + 1); + } + } +} + +/// What makes two placements the same part: the product's name, the file items its bodies come +/// from and, below it, the same parts placed the same way. +fn node_key(imp: &StepImport, node: &StepNode, depth: usize) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + node.name.hash(&mut h); + for b in &node.bodies { + imp.bodies.get(*b).map(|b| b.source_item).hash(&mut h); + } + if depth < 64 { + for c in &node.children { + node_key(imp, c, depth + 1).hash(&mut h); + for v in c.transform.iter().flatten() { + // Placements equal to within rounding are the same placement. + ((v * 1e6).round() as i64).hash(&mut h); + } + } + } + h.finish() +} + +/// The import's component, as the command reports it. +pub(crate) struct Placed { + pub component: u64, + pub occurrence: Option, + pub name: String, + /// Components made below it (an assembly's parts and sub-assemblies). + pub components: usize, + /// Rigid joints holding its contents together. + pub joints: Vec, +} + +impl Placed { + pub fn report(&self, out: &mut Value) { + out["component"] = json!(self.component); + out["component_name"] = json!(self.name); + out["occurrence"] = json!(self.occurrence); + if self.components > 0 { + out["subcomponents"] = json!(self.components); + out["rigid_joints"] = json!(self.joints.len()); + } + } +} + +/// The component an import makes: named `name`, in the active component at its origin. +fn new_component(s: &mut Session, name: &str) -> Result<(u64, Option)> { + let parent = s.active_component; + let id = s.doc_mut().add_component(Some(name), parent)?; + Ok((id, s.doc.occurrence_of(id).map(|o| o.id))) +} + +/// The name of an imported file's component: `name` if given, else the file's name without its +/// extension (`bracket.step` → "bracket"). +pub(crate) fn component_name(path: &str, name: Option<&str>) -> String { + if let Some(n) = name.map(str::trim).filter(|n| !n.is_empty()) { + return n.chars().take(128).collect(); + } + std::path::Path::new(path) + .file_stem() + .map(|s| s.to_string_lossy().trim().to_string()) + .filter(|s| !s.is_empty()) + .map(|s| s.chars().take(128).collect()) + .unwrap_or_else(|| "Import".into()) +} + +/// Add an import feature `kind` (named `feature_name`, with the file's `body_names`) inside a +/// new component named `name`, and for a STEP assembly (`imp`) the components of its tree. +/// Evaluates the design. Returns the feature and the component. +pub(crate) fn add_in_component( + s: &mut Session, + mut kind: FeatureKind, + feature_name: &str, + body_names: Vec, + name: &str, + imp: Option<&StepImport>, +) -> Result<(u64, Placed)> { + let (top, top_occ) = new_component(s, name)?; + let plan = imp.map(Plan::of).unwrap_or_default(); + // The components, each made at its first placement; the others are further occurrences. + let mut comps: Vec = Vec::with_capacity(plan.slots.len()); + let mut first_occ: Vec> = Vec::with_capacity(plan.slots.len()); + let mut joints: Vec<(u64, u64, Mat)> = Vec::new(); + for place in &plan.places { + let parent_comp = place.parent.and_then(|p| comps.get(p).copied()).unwrap_or(top); + let parent_occ = match place.parent { + Some(p) => first_occ.get(p).copied().flatten(), + None => top_occ, + }; + let occ = if let Some(&comp) = comps.get(place.slot) { + s.doc_mut().add_occurrence(comp, parent_comp, place.transform)? + } else { + let slot = plan.slots.get(place.slot).ok_or_else(|| EngineError::Other("import: component plan".into()))?; + let label = if slot.name.is_empty() { None } else { Some(slot.name.as_str()) }; + let comp = s.doc_mut().add_component(label, parent_comp)?; + comps.push(comp); + let occ = s.doc.occurrence_of(comp).map(|o| o.id).ok_or_else(|| EngineError::Other("import: occurrence".into()))?; + if let Some(o) = s.doc_mut().occurrences.iter_mut().find(|o| o.id == occ) { + o.transform = place.transform; + } + first_occ.push(Some(occ)); + occ + }; + if let Some(a) = parent_occ { + joints.push((a, occ, place.transform)); + } + } + if let FeatureKind::Import { skip, .. } = &mut kind { + skip.clone_from(&plan.skip); + } + // The bodies' names, as the feature will give them (checked once it is built). + let names = match (&kind, imp) { + (FeatureKind::Import { skip, .. }, Some(imp)) => solidcraft_doc::import_body_names(&s.model.state(), &body_names, imp, skip), + _ => Vec::new(), + }; + let id = s.doc_mut().add_feature(kind, Some(feature_name))?; + if let Some(f) = s.doc_mut().feature_mut(id) { + f.component = top; + f.body_names = body_names; + } + let moves: Vec<(usize, u64, Mat)> = plan.bodies.iter().filter_map(|(b, slot, offset)| comps.get(*slot).map(|c| (*b, *c, *offset))).collect(); + put_bodies(s, &names, &moves); + let joint_ids = add_rigid_joints(s, &joints)?; + s.active_sketch = None; + s.refresh(); + if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) { + return Err(EngineError::Other(e)); + } + // The names as built (they differ from the forecast only when the timeline was not up to + // date): the bodies go by those. + if !moves.is_empty() { + let built: Vec = s.model.state().bodies.iter().filter(|b| b.feature == id).map(|b| b.name.clone()).collect(); + let forecast: Vec = names.iter().flatten().cloned().collect(); + if built != forecast { + let mut it = built.into_iter(); + let actual: Vec> = names.iter().map(|n| n.as_ref().and_then(|_| it.next())).collect(); + for n in forecast { + s.doc_mut().body_components.remove(&n); + s.doc_mut().body_offsets.remove(&n); + } + put_bodies(s, &actual, &moves); + s.refresh(); + } + } + let name = s.doc.components.iter().find(|c| c.id == top).map(|c| c.name.clone()).unwrap_or_default(); + Ok((id, Placed { component: top, occurrence: top_occ, name, components: comps.len(), joints: joint_ids })) +} + +/// Record that body `names[b]` lives in component `c`, placed by `offset`. +fn put_bodies(s: &mut Session, names: &[Option], moves: &[(usize, u64, Mat)]) { + if moves.is_empty() { + return; + } + let d = s.doc_mut(); + for (b, comp, offset) in moves { + let Some(Some(name)) = names.get(*b) else { continue }; + d.body_components.insert(name.clone(), *comp); + if !solidcraft_doc::is_identity(offset) { + d.body_offsets.insert(name.clone(), *offset); + } + } +} + +/// Rigid joints holding each placement where it is in its parent (`(a, b, transform of b in +/// a)`), shown in the browser's joints but not drawn in the view. Returns their ids. +fn add_rigid_joints(s: &mut Session, pairs: &[(u64, u64, Mat)]) -> Result> { + if pairs.is_empty() { + return Ok(Vec::new()); + } + if s.doc.assembly.joints.len() + pairs.len() > solidcraft_doc::joints::MAX_JOINTS { + // Too many to hold together: the parts are placed, not locked. + return Ok(Vec::new()); + } + let mut id = s.doc.assembly.joints.iter().map(|j| j.id).max().unwrap_or(0); + let mut ids = Vec::with_capacity(pairs.len()); + let point = |m: &Mat, p: Vec3| solidcraft_doc::apply_point(m, p); + let vector = |m: &Mat, v: Vec3| solidcraft_doc::apply_vector(m, v); + let made: Vec = pairs + .iter() + .map(|(a, b, m)| { + id += 1; + ids.push(id); + // As built: B's origin, seen from A (B's placement in A) and from B itself. + Joint { + id, + name: format!("Rigid Group {a}:{b}"), + kind: JointKind::Rigid, + a: JointOrigin { + occurrence: *a, + snap: Snap::Frame { origin: point(m, Vec3::ZERO), z: vector(m, Vec3::Z), x: Some(vector(m, Vec3::X)) }, + }, + b: JointOrigin { occurrence: *b, snap: Snap::Frame { origin: Vec3::ZERO, z: Vec3::Z, x: Some(Vec3::X) } }, + values: Vec::new(), + limits: Vec::new(), + flip: true, + offset: 0.0, + angle: 0.0, + suppressed: false, + } + }) + .collect(); + s.doc_mut().assembly.joints.extend(made); + // Dozens of coinciding glyphs say nothing: the group shows in the browser instead. + s.visibility.hidden_joints.retain(|h| !ids.contains(h)); + s.visibility.hidden_joints.extend(ids.iter().copied()); + Ok(ids) +} + +impl Session { + /// The component whose contents hold occurrence `occ` where it is: a rigid joint ties it to + /// a placement of the component it sits in (an imported assembly's part). `None` when it is + /// free to move on its own. + pub fn held_by_rigid_group(&self, occ: u64) -> Option { + let parent = self.doc.occurrences.iter().find(|o| o.id == occ)?.parent; + if parent == 0 { + return None; + } + let of_parent = |o: u64| self.doc.occurrences.iter().any(|x| x.id == o && x.component == parent); + self.doc + .assembly + .joints + .iter() + .any(|j| { + j.kind == JointKind::Rigid + && !j.suppressed + && ((j.b.occurrence == occ && of_parent(j.a.occurrence)) || (j.a.occurrence == occ && of_parent(j.b.occurrence))) + }) + .then_some(parent) + } + + /// The rigid joints that hold `component`'s contents together (an imported assembly's rigid + /// group): both ends at its own occurrences or at occurrences inside it. `component.unlock` + /// removes them. + pub fn contents_joints(&self, component: u64) -> Vec { + if component == 0 { + return Vec::new(); + } + let inside = |occ: u64| { + self.doc + .occurrences + .iter() + .find(|o| o.id == occ) + .is_some_and(|o| o.component == component || self.doc.component_within(o.parent, component)) + }; + self.doc + .assembly + .joints + .iter() + .filter(|j| j.kind == JointKind::Rigid && inside(j.a.occurrence) && inside(j.b.occurrence)) + .map(|j| j.id) + .collect() + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 59a0a7ce..c1e527b7 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -833,6 +833,12 @@ impl Session { self.active_component = computed.active_component; self.active_occurrence_path = computed.active_occurrence_path; self.pending_moves = computed.pending_moves; + // Joints the computation hid (an import's rigid group) stay hidden. + for j in computed.visibility.hidden_joints { + if !self.visibility.hidden_joints.contains(&j) { + self.visibility.hidden_joints.push(j); + } + } for message in computed.log { self.echo(message); } diff --git a/crates/engine/src/product_scene.rs b/crates/engine/src/product_scene.rs index e389d50f..b90d0300 100644 --- a/crates/engine/src/product_scene.rs +++ b/crates/engine/src/product_scene.rs @@ -182,7 +182,15 @@ mod tests { .unwrap(); let mut document = crate::doc::Document::default(); document - .add_feature(crate::doc::FeatureKind::Import { file: "original-generated.step".into(), step: step.into(), components: Vec::new() }, None) + .add_feature( + crate::doc::FeatureKind::Import { + file: "original-generated.step".into(), + step: step.into(), + components: Vec::new(), + skip: Vec::new(), + }, + None, + ) .unwrap(); let mut s = Session::new(document); let first = extract(&s).unwrap(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 28f4de3d..b990ace9 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -307,6 +307,7 @@ mod blend_fit; mod boolean_diagnostics; mod component_frames; mod curved_seam_round; +mod import_component; mod large_step_import; mod loft_cut; mod loft_ring; @@ -1057,7 +1058,9 @@ fn join_onto_a_finely_faceted_import() { let prism = volume(&mut s); let mut d = Session::default(); - run(&mut d, "file.insert_step", json!({"path": p.to_string_lossy()})); + // The import is a component (#1386): the pin is made in it to join its body. + let r = run(&mut d, "file.insert_step", json!({"path": p.to_string_lossy()})); + run(&mut d, "component.activate", json!({"component": r["component"]})); run(&mut d, "sketch.create", json!({"plane": "XY", "name": "Pin"})); run(&mut d, "sketch.rectangle.two_point", json!({"p0": [-5, -5], "p1": [5, 5]})); run(&mut d, "sketch.finish", json!({})); diff --git a/crates/engine/src/tests/import_component.rs b/crates/engine/src/tests/import_component.rs new file mode 100644 index 00000000..061f40d9 --- /dev/null +++ b/crates/engine/src/tests/import_component.rs @@ -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 { + 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::>(), ["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, 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 = 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 = 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); +} diff --git a/crates/engine/src/tests/step_import_options.rs b/crates/engine/src/tests/step_import_options.rs index bdf7ebb7..435c4d72 100644 --- a/crates/engine/src/tests/step_import_options.rs +++ b/crates/engine/src/tests/step_import_options.rs @@ -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"]); } diff --git a/crates/io/src/design.rs b/crates/io/src/design.rs index 8dc7630b..0d321760 100644 --- a/crates/io/src/design.rs +++ b/crates/io/src/design.rs @@ -640,7 +640,8 @@ mod tests { fn seekable_preview_defers_large_source_blob_until_document_read() { let mut doc = Document::new("Deferred source"); let step = "ISO-10303-21;".repeat(200_000); - doc.add_feature(FeatureKind::Import { file: "large.step".into(), step: step.clone().into(), components: Vec::new() }, None).unwrap(); + doc.add_feature(FeatureKind::Import { file: "large.step".into(), step: step.clone().into(), components: Vec::new(), skip: Vec::new() }, None) + .unwrap(); let preview = DesignPreview { meshes: vec![mesh()] }; let bytes = write_design_container_with_preview(&doc, DesignCompression::Fast, Some(&preview)).unwrap(); let count = std::rc::Rc::new(std::cell::Cell::new(0)); @@ -699,7 +700,8 @@ mod tests { fn native_cache_round_trip_and_preview_defer_original_sources() { let mut doc = Document::new("Native cache"); let step = "ISO-10303-21;".repeat(200_000); - doc.add_feature(FeatureKind::Import { file: "large.step".into(), step: step.clone().into(), components: Vec::new() }, None).unwrap(); + doc.add_feature(FeatureKind::Import { file: "large.step".into(), step: step.clone().into(), components: Vec::new(), skip: Vec::new() }, None) + .unwrap(); let bodies = native_geometry(); let bytes = write_design_container_with_cache(&doc, DesignCompression::Fast, None, Some(&bodies)).unwrap(); let entries = parts(&bytes); @@ -811,7 +813,12 @@ mod tests { let mut doc = Document::new("Payloads"); for _ in 0..2 { doc.add_feature( - FeatureKind::Import { file: "example.step".into(), step: "ISO-10303-21;\nEND-ISO-10303-21;".into(), components: Vec::new() }, + FeatureKind::Import { + file: "example.step".into(), + step: "ISO-10303-21;\nEND-ISO-10303-21;".into(), + components: Vec::new(), + skip: Vec::new(), + }, None, ) .unwrap(); @@ -1167,7 +1174,8 @@ mod tests { .collect(); let step = solidcraft_kernel::step_export(&bodies.iter().collect::>(), "SolidCraft storage benchmark").unwrap(); let mut doc = Document::new("Generated STEP benchmark"); - doc.add_feature(FeatureKind::Import { file: "generated.step".into(), step: step.into(), components: Vec::new() }, None).unwrap(); + doc.add_feature(FeatureKind::Import { file: "generated.step".into(), step: step.into(), components: Vec::new(), skip: Vec::new() }, None) + .unwrap(); measure_storage(&doc, "step"); } diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 6ef6f575..5b28962e 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -182,7 +182,7 @@ pub fn step_import_feature_named(bytes: &[u8], file: &str, names: StepNames) -> Ok(StepFeature { name, body_names, - kind: solidcraft_doc::FeatureKind::Import { file: file_name, step: text.into(), components: imp.tree.clone() }, + kind: solidcraft_doc::FeatureKind::Import { file: file_name, step: text.into(), components: imp.tree.clone(), skip: Vec::new() }, warnings: iges_warnings.into_iter().chain(imp.warnings.iter().cloned()).collect(), import: imp, }) diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index cddc5059..6f895754 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -214,7 +214,7 @@ fn open_stands_a_y_up_step_file_up() { assert_eq!(r["features"], 1, "{r}"); assert!((height(&mut s) - 5.0).abs() < 1e-6, "as written"); let r = tool(&mut s, "open", json!({"path": step, "up": "y", "names": "product"})); - assert_eq!(r["features"], 2, "the import and the turn: {r}"); + assert_eq!(r["features"], 1, "the import, its component turned: {r}"); assert!((height(&mut s) - 40.0).abs() < 1e-6, "Y became Z"); for args in [json!({"path": step, "up": "x"}), json!({"path": step, "names": "label"}), json!({"path": step, "up": 1})] { diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 42be3d77..fafbb136 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -249,10 +249,10 @@ pub fn tool_definitions() -> Vec { "open", "Open a design", "Open a .solidcraft design file, a STEP file (.step/.stp: a new design whose bodies come from the file, as an \ - Import base feature) or a 3MF/STL mesh (.3mf/.stl: mesh bodies). Replaces the current design; insert into it \ + Import base feature in a component named after the file; an assembly's parts become nested components) or a \ + 3MF/STL mesh (.3mf/.stl: mesh bodies in such a component); the result reports the component (id, component_name). Replaces the current design; insert into it \ instead with the file.insert_step (STEP) or file.insert_mesh (3MF/STL) commands. A STEP file from a system \ - that has Y up opens lying on its back: `up: \"y\"` turns it a quarter turn about X (Y becomes Z) with a Move \ - feature. `names` picks what the bodies are named after when the solids' own names say nothing useful.", + that has Y up opens lying on its back: `up: \"y\"` turns its component a quarter turn about X (Y becomes Z). `names` picks what the bodies are named after when the solids' own names say nothing useful.", obj( json!({ "path": string("Path to a .solidcraft, .step/.stp, .3mf or .stl file"), diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index 00eece9a..61d52534 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -600,7 +600,7 @@ fn occurrence_items(app: &SolveApp, path: &[u64]) -> Vec { } "ui.moveOccurrence" => { item.params = Some(json!({"occurrence": placement.occurrence, "path": path})); - item.enabled = !placement.grounded; + item.enabled = !placement.grounded && app.session.held_by_rigid_group(placement.occurrence).is_none(); } "ui.properties" => item.params = Some(json!({"occurrence": placement.occurrence, "path": path})), "occurrence.copy" => item.params = Some(json!({"occurrence": placement.occurrence, "translate": [10,10,0]})), @@ -642,9 +642,18 @@ fn component_items(app: &SolveApp, id: u64) -> Vec { let oid = occ.map(|o| o.id).unwrap_or(0); v.push(act("ui.activate", "Activate", "component").with(json!({ "component": id })).on(!active)); v.push(cmd(app, "occurrence.ground", if grounded { "Unground" } else { "Ground" }).with(json!({ "occurrence": oid, "grounded": !grounded }))); - v.push(act("ui.moveOccurrence", "Move/Copy", "move").key("M").with(json!({ "occurrence": oid })).on(!grounded && occ.is_some())); + v.push( + act("ui.moveOccurrence", "Move/Copy", "move") + .key("M") + .with(json!({ "occurrence": oid })) + .on(!grounded && occ.is_some() && app.session.held_by_rigid_group(oid).is_none()), + ); v.push(cmd(app, "component.capture_position", "Capture Position").on(!app.session.pending_moves.is_empty())); v.push(cmd(app, "component.revert_position", "Revert Position").on(!app.session.pending_moves.is_empty())); + // An imported assembly moves as one until its rigid group is released. + if !app.session.contents_joints(id).is_empty() { + v.push(cmd(app, "component.unlock", "Unlock Contents").with(json!({ "component": id }))); + } v.push(Item::sep()); } else { v.push(act("ui.activate", "Activate", "component").with(json!({ "component": id })).on(!active)); diff --git a/crates/ui-egui/src/file_load.rs b/crates/ui-egui/src/file_load.rs index 1beeac50..6c37a472 100644 --- a/crates/ui-egui/src/file_load.rs +++ b/crates/ui-egui/src/file_load.rs @@ -740,7 +740,7 @@ pub fn tick(app: &mut SolveApp, ctx: &egui::Context) { app.fit_view(); // A caller that said which way is up has been answered: no offer to stand the part up. if (request.kind == Kind::Open || request.insert()) && !request.options.contains_key("up") { - crate::stand_up::offer(app, &request.path, &running.before); + crate::stand_up::offer(app, &request.path, &running.before, Some(&made.result)); } let warnings = made.result.get("warnings").and_then(Value::as_array); if let Kind::Recover { .. } = request.kind { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 11662259..ea96f0a6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -312,6 +312,8 @@ pub struct SolveApp { pub recoverable: Vec, /// The bodies of a just-opened Y-up file, offered to be stood up (`stand_up`). pub stand_up: Option>, + /// The placement of the component that file came in as: the offer turns it. + pub stand_up_occurrence: Option, /// The anti-aliasing sample counts the graphics device supports (empty without a GPU). pub msaa_supported: Vec, /// Settings put back at start because they kept SolidCraft from starting, one line each, @@ -407,6 +409,7 @@ impl SolveApp { autosave: None, recoverable: Vec::new(), stand_up: None, + stand_up_occurrence: None, msaa_supported: Vec::new(), settings_reset: Vec::new(), big_operation: false, @@ -910,9 +913,9 @@ impl SolveApp { pub fn open_path(&mut self, path: &str) { self.home.open = false; documents::prepare_open(self); - if self.run("doc.open", json!({ "path": path })).is_ok() { + if let Ok(r) = self.run("doc.open", json!({ "path": path })) { self.fit_view(); - stand_up::offer(self, path, &[]); + stand_up::offer(self, path, &[], Some(&r)); } } @@ -930,7 +933,7 @@ impl SolveApp { let before: Vec = self.session.world_state().bodies.iter().map(|b| b.name.clone()).collect(); if let Ok(r) = self.run(cmd, json!({ "path": path })) { self.fit_view(); - stand_up::offer(self, path, &before); + stand_up::offer(self, path, &before, Some(&r)); if let Some(w) = r["warnings"].as_array().filter(|w| !w.is_empty()) { self.set_status(format!("imported with {} warning(s): {}", w.len(), w.first().and_then(Value::as_str).unwrap_or("")), false); } diff --git a/crates/ui-egui/src/stand_up.rs b/crates/ui-egui/src/stand_up.rs index c6274776..83ba676e 100644 --- a/crates/ui-egui/src/stand_up.rs +++ b/crates/ui-egui/src/stand_up.rs @@ -1,7 +1,7 @@ //! Standing up a Y-up file (#3). Many CAD programs save parts with Y up, so a STEP or IGES file //! from them opens lying on its back in a Z-up design. After such a file opens, SolidCraft -//! offers to stand it up: a quarter turn about X (Y → Z) of the bodies it brought, as one -//! Move feature that can be undone or edited. Preferences › Design › "Offer to stand up Y-up +//! offers to stand it up: a quarter turn about X (Y → Z) of the component the file came in as +//! (#1386), so an assembly's parts and instances turn together, in one step that can be undone. Preferences › Design › "Offer to stand up Y-up //! files" turns the offer off; with Y up as the default orientation it isn't needed. use serde_json::json; @@ -9,8 +9,9 @@ use serde_json::json; use crate::SolveApp; /// The bodies a just-opened file brought (`before`: body names before it opened, none for a -/// new design): offer to stand them up. -pub fn offer(app: &mut SolveApp, path: &str, before: &[String]) { +/// new design), and the import's component placement from the command's `result`: offer to +/// stand them up. +pub fn offer(app: &mut SolveApp, path: &str, before: &[String], result: Option<&serde_json::Value>) { let io = &solidcraft_engine::io::is_step_path; if !(io(path) || solidcraft_engine::io::is_iges_path(path)) || !app.preferences.offer_stand_up || app.preferences.up_axis != "z" { return; @@ -18,13 +19,19 @@ pub fn offer(app: &mut SolveApp, path: &str, before: &[String]) { let bodies: Vec = app.session.world_state().bodies.iter().map(|b| b.name.clone()).filter(|n| !before.contains(n)).collect(); if !bodies.is_empty() { app.stand_up = Some(bodies); + app.stand_up_occurrence = result.and_then(|r| r.get("occurrence")).and_then(serde_json::Value::as_u64); } } /// Turn the offered bodies a quarter turn about X (Y up becomes Z up). pub fn stand_up(app: &mut SolveApp) -> Result<(), String> { let Some(bodies) = app.stand_up.take() else { return Err("no file is waiting to be stood up".into()) }; - app.run("solid.move", json!({ "bodies": bodies, "axis": [1, 0, 0], "angle": "90 deg" })).map_err(|e| e.to_string())?; + match app.stand_up_occurrence.take() { + // The import's component turns about its parent's origin, contents and all. + Some(occurrence) => app.run("occurrence.move", json!({ "occurrence": occurrence, "axis": [1, 0, 0], "angle": "90 deg" })), + None => app.run("solid.move", json!({ "bodies": bodies, "axis": [1, 0, 0], "angle": "90 deg" })), + } + .map_err(|e| e.to_string())?; app.animate_view("home"); Ok(()) } @@ -62,9 +69,13 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { app.set_status(e, true); } } - Some("leave") => app.stand_up = None, + Some("leave") => { + app.stand_up = None; + app.stand_up_occurrence = None; + } Some("never") => { app.stand_up = None; + app.stand_up_occurrence = None; app.preferences.offer_stand_up = false; } _ => {} diff --git a/crates/ui-egui/src/viewport_appearance_tests.rs b/crates/ui-egui/src/viewport_appearance_tests.rs index ef5498c1..05d88d33 100644 --- a/crates/ui-egui/src/viewport_appearance_tests.rs +++ b/crates/ui-egui/src/viewport_appearance_tests.rs @@ -14,8 +14,9 @@ fn placed_step_body_uses_imported_opacity_until_a_design_look_overrides_it() { .unwrap(); let mut document = doc::Document::default(); let component = document.add_component(Some("Imported part"), 0).unwrap(); - let feature = - document.add_feature(doc::FeatureKind::Import { file: "glass.step".into(), step: step.into(), components: Vec::new() }, None).unwrap(); + let feature = document + .add_feature(doc::FeatureKind::Import { file: "glass.step".into(), step: step.into(), components: Vec::new(), skip: Vec::new() }, None) + .unwrap(); document.features.iter_mut().find(|f| f.id == feature).unwrap().component = component; let mut session = Session::new(document); let source = session.model.state().bodies[0].name.clone(); diff --git a/docs/book/src/guide/files.md b/docs/book/src/guide/files.md index 90384e1f..811c9856 100644 --- a/docs/book/src/guide/files.md +++ b/docs/book/src/guide/files.md @@ -16,6 +16,13 @@ Opening a STEP or IGES file creates a design with the file's bodies as a base fe inserting one adds them to the current design. Later features build on them. Mesh bodies render, measure, move and export, but solid features need B-rep bodies. +Every imported file (STEP, IGES, 3MF, STL or OBJ) comes in as a new component named after the +file, so its bodies never mix with yours. A STEP assembly keeps its structure: its parts and +sub-assemblies become nested components, and a part used several times is one component with an +occurrence for each place. The imported assembly is rigid: moving its component moves everything, +and its parts don't move on their own. Right-click the component and choose **Unlock Contents** to +joint the parts one by one. Undo removes a whole import in one step. + **File → Save as mesh…** (also on the Mesh tab under Modify, and in the right-click menu of a body) saves the selected bodies as STL, 3MF or 3MF with face tags (below). With **Send to slicer** ticked, it writes the file to a temporary folder and opens it in the chosen slicer instead of asking for a file name. The slicer diff --git a/docs/design-container.md b/docs/design-container.md index f17d886f..b4f27588 100644 --- a/docs/design-container.md +++ b/docs/design-container.md @@ -7,7 +7,7 @@ self-contained input/output format. `io::write_design` keeps its JSON contract; `io::write_design_container` is the fallible container writer. Packages have `container.json` with `{"format":"solidcraft-container/1"}` and -`document.json` with the versioned parametric document (currently `solidcraft/21`). +`document.json` with the versioned parametric document (currently `solidcraft/22`). The independent versions let document migrations evolve without changing blob encodings. Existing serialized document fields are retained automatically, without a second copy of the document schema or a deep clone of embedded payloads. diff --git a/docs/features.md b/docs/features.md index 8c4a2335..6fbd2309 100644 --- a/docs/features.md +++ b/docs/features.md @@ -1,6 +1,6 @@ # Features -SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **367 commands.** Planned (registered, not built yet): 4. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec ''`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md). +SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **368 commands.** Planned (registered, not built yet): 4. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec ''`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md). ## Sketch (99) @@ -221,7 +221,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm | Assign Plastic Rule | `plastic.assign_rule` | PLASTIC › SETUP | | List Plastic Rules | `plastic.rules` | – | -## Components (16) +## Components (17) | Command | Id | Toolbar | |---|---|---| @@ -232,6 +232,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm | Component Physical Properties | `component.properties` | – | | Rename Component | `component.rename` | – | | Delete Component | `component.delete` | – | +| Unlock Contents | `component.unlock` | – | | Move Bodies to Component | `component.move_bodies` | – | | Move Sketches to Component | `component.move_sketches` | – | | Paste New | `component.paste_new` | – | diff --git a/docs/step-import.md b/docs/step-import.md index c269e6ee..019722f2 100644 --- a/docs/step-import.md +++ b/docs/step-import.md @@ -6,13 +6,23 @@ SolidCraft reads STEP (ISO 10303-21; AP203, AP214 and AP242 geometry) with its o - **Insert** into the current design: File → Insert STEP… or SOLID → INSERT (`file.insert_step {path}`). - **Drag and drop** a STEP file on the window: inserted when the design has features, opened otherwise. -Read: manifold solid breps (with voids), faceted breps, shell-based surface models; planes, cylinders, cones, spheres, tori, B-spline and rational B-spline surfaces (with knots, Bézier, uniform, quasi-uniform), surfaces of revolution and linear extrusion, trimmed surfaces; lines, circles, ellipses, B-spline curves, polylines, trimmed/surface/seam curves; length and angle units (SI prefixes, conversion-based units such as inches); product names (body names come from the solid's name, else the product's; `names` below chooses); surface colours; assemblies (next-assembly-usage occurrences placed by item-defined transformations, and mapped items) — the product tree is kept in the feature for the components milestone. Files are untrusted: sizes, entity counts and nesting are capped and every kernel call is guarded. What cannot be read becomes a warning on the feature (a face left out gives an open body); not read yet: offset surfaces, pcurve-only edges. +## Imports are components + +Every import (STEP, IGES, 3MF, STL, OBJ; inserted, dropped, opened as a new design, or run as `file.insert_step`, `file.insert_mesh` or `doc.open` from a script, the CLI or MCP) becomes a **new component named after the file** (`bracket.step` → component `bracket`), placed in the active component at the file's origin. The Import feature belongs to it, so the file's bodies never land loose next to your own. Opening a file as a design makes the component too, so the browser shows the file's name. The command's reply reports it: `component` (id), `component_name` and `occurrence`. + +A STEP **assembly** keeps its structure: each product under the file's root becomes a component nested under the import's, named after the product, at the place the file gives it. A part (or sub-assembly) the file places more than once becomes **one component with an occurrence per placement**: the Import feature builds the part once and skips the file's later copies (`skip` in the feature, format 22), and the occurrences show it everywhere else. The reply adds `subcomponents` (how many components were made below the import's) and `rigid_joints`. + +The contents of an imported assembly are **rigid by default**: rigid joints (a rigid group, its glyphs not drawn) hold each part where the file put it in its parent, so moving the import's component moves everything, and an inner part doesn't move on its own; joint degrees of freedom count the whole import as one body. To joint the parts one by one, release them with **Unlock Contents** on the component's browser menu (`component.unlock {component}`), one undo step. Undo takes a whole import away in one step. + +Designs saved before imports made components keep their Import where it is (no component is made for it) and rebuild as they did. + +Read: manifold solid breps (with voids), faceted breps, shell-based surface models; planes, cylinders, cones, spheres, tori, B-spline and rational B-spline surfaces (with knots, Bézier, uniform, quasi-uniform), surfaces of revolution and linear extrusion, trimmed surfaces; lines, circles, ellipses, B-spline curves, polylines, trimmed/surface/seam curves; length and angle units (SI prefixes, conversion-based units such as inches); product names (body names come from the solid's name, else the product's; `names` below chooses); surface colours; assemblies (next-assembly-usage occurrences placed by item-defined transformations, and mapped items) — the product tree is kept in the feature and becomes the import's components (above). Files are untrusted: sizes, entity counts and nesting are capped and every kernel call is guarded. What cannot be read becomes a warning on the feature (a face left out gives an open body); not read yet: offset surfaces, pcurve-only edges. ## Files from other CAD systems Files from different systems differ in which way is up and in what they call their solids. `doc.open`, `file.insert_step` and the MCP `open` tool take two options for STEP and IGES files; without them a file comes in as written. -- **`up`**: `"z"` (the default) keeps the file's axes. Many systems write Y up, so such a part lies on its back in SolidCraft's Z-up space. `"up": "y"` turns the file's bodies a quarter turn about X (+90°: Y becomes Z) with one Move feature right after the Import, the one the window's "Stand it up" offer adds. The import and the turn are one undo step, the reply names the Move as `stand_up`, and only the bodies of the file turn, not the ones already in the design. The window offers to stand a part up after a STEP file opens unless the call said which way is up (`up: "z"` as well as `"y"`). +- **`up`**: `"z"` (the default) keeps the file's axes. Many systems write Y up, so such a part lies on its back in SolidCraft's Z-up space. `"up": "y"` turns the import's component a quarter turn about X (+90°: Y becomes Z), as the window's "Stand it up" offer does, so an assembly's parts and instances turn together. The import and the turn are one undo step, the reply names the turned occurrence as `stand_up`, and only the file's component turns, not what is already in the design. The window offers to stand a part up after a STEP file opens unless the call said which way is up (`up: "z"` as well as `"y"`). - **`names`**: what the bodies are called. `"solid"` (the default) is the name of the solid in the file, and the product's name where the solid has none. Some systems fill a solid's name with the last feature that made it (`Cut-Extrude3`), so every body of an assembly has a name that says nothing about its part. `"product"` names each body after the product (the part) that holds it, and `"instance"` after the assembly occurrence (`Post-2`), or the product where the body is not in an occurrence. A name the file leaves out, or fills with a placeholder, falls back to the next one (instance, product, solid). Repeated names are numbered as any body names are (`Post`, `Post (2)`). The names are stored with the Import feature, so the design reopens with them. The default stays the solid's name because SolidCraft's own STEP export writes a body's name there. ```json