From 46e21127dddb261a586f247ff372e17c84b03450 Mon Sep 17 00:00:00 2001 From: Jonas Wrede Date: Tue, 13 Oct 2026 12:40:58 +0200 Subject: [PATCH] feat: insert another design as a linked component (#596) component.insert_design adds a component holding another design's solid bodies, linked to its file (stored relative to the assembly once saved). component.update_links reads the linked designs again, component.break_link keeps the bodies and forgets the file, and component.links lists them. Links are saved under format 16; an older build keeps the bodies and drops the link. Fixes #533 --- crates/doc/src/document.rs | 16 ++ crates/doc/src/format.rs | 3 + crates/engine/src/cmd/component_link.rs | 329 ++++++++++++++++++++++ crates/engine/src/cmd/file.rs | 20 ++ crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/occurrence_tests.rs | 1 - docs/book/src/guide/assemblies.md | 10 + 7 files changed, 380 insertions(+), 1 deletion(-) create mode 100644 crates/engine/src/cmd/component_link.rs diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 0e962e81..e17fc182 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -1698,6 +1698,18 @@ pub struct StandardPart { pub length: Option, } +/// A component whose contents come from another design file (Insert Design): where that file +/// is, and the Import feature in the component that holds its bodies (Update Links replaces +/// them; Break Link keeps them and forgets the file). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct DesignLink { + /// The design file: relative to this design's folder once this design is saved, else as + /// given (`/` separates folders). + pub path: String, + /// The Import feature holding the linked design's bodies. + pub feature: u64, +} + /// The document. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Document { @@ -1731,6 +1743,9 @@ pub struct Document { /// Components that are standard parts (by component id): what to rebuild them from. #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] pub parts: std::collections::BTreeMap, + /// Components linked to another design file (by component id). + #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub links: std::collections::BTreeMap, /// Configurations (rows of parameter values and suppressions). #[serde(default, skip_serializing_if = "crate::config::ConfigTable::is_empty")] pub configs: crate::config::ConfigTable, @@ -1858,6 +1873,7 @@ impl Document { plastic: Default::default(), configs: Default::default(), parts: Default::default(), + links: Default::default(), body_components: Default::default(), body_offsets: Default::default(), materials: Default::default(), diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs index 25afaa8c..279ef174 100644 --- a/crates/doc/src/format.rs +++ b/crates/doc/src/format.rs @@ -60,6 +60,9 @@ //! `font` is in the built-in font, as it always was. //! A Move feature can also keep its source bodies and transform a copy (`copy: true`); a //! move without it saves as before. +//! A component can link to another design file (`links`: component id → `{path, feature}`, +//! the path relative to the design's folder); the linked bodies are kept in the component's +//! Import feature, so an older build opens the design with them and only loses the link. //! A design can also define its own physical materials (`user_materials`: name, density in //! g/cm³, optional look), which bodies (`materials`) and configuration material cells name //! like built-in ones; a design without them saves as before. diff --git a/crates/engine/src/cmd/component_link.rs b/crates/engine/src/cmd/component_link.rs new file mode 100644 index 00000000..dafa9e98 --- /dev/null +++ b/crates/engine/src/cmd/component_link.rs @@ -0,0 +1,329 @@ +//! Linked designs: a component whose bodies come from another design file (Insert Design). +//! +//! The linked design is opened and evaluated on its own, and its solid bodies, as placed in it, +//! join a new component through an Import feature (exact B-rep, kept in this design so it opens +//! without the part file). The link remembers the file, relative to this design's folder once it +//! is saved; Update Links reads the file again and replaces the Import feature's bodies, and +//! Break Link keeps the bodies and forgets the file. + +use std::path::{Component as PathPart, Path, PathBuf}; + +use serde_json::{Value, json}; +use solidcraft_doc::{DesignLink, Document, FeatureKind}; + +use super::CommandSpec; +use crate::params::{bad, str_, vec3}; +use crate::{EngineError, Result, Session}; + +pub static COMMANDS: &[CommandSpec] = &[ + CommandSpec::new("component.insert_design", "Insert Design", insert_design).icon("component").params( + "path: a .solidcraft design → a new component linked to it, holding its solid bodies as placed in that design; \ + name? (default: the design's name); parent?: component id or name (default: the active one); translate?: [x,y,z]", + ), + CommandSpec::new("component.update_links", "Update Links", update_links).params( + "component?: id or name (default: every linked component) — reads the linked designs again and replaces their bodies where they changed", + ), + CommandSpec::new("component.break_link", "Break Link", break_link) + .params("component: id or name of a linked component — keeps its bodies as they are and forgets the design file"), + CommandSpec::new("component.links", "List Links", list_links) + .noundo() + .params("→ linked components: component, name, path, file (resolved), found"), +]; + +/// The folder relative link paths are kept against: the design's own, once it has a file. +fn base_dir(path: Option<&str>) -> Option { + path.and_then(|p| absolute(Path::new(p)).parent().map(Path::to_path_buf)) +} + +/// A path as given, made absolute when the platform can, with `.` and `..` worked out. +fn absolute(p: &Path) -> PathBuf { + let full = std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf()); + let mut out = PathBuf::new(); + for c in full.components() { + match c { + PathPart::CurDir => {} + PathPart::ParentDir if matches!(out.components().next_back(), Some(PathPart::Normal(_))) => { + out.pop(); + } + c => out.push(c.as_os_str()), + } + } + out +} + +/// `target` relative to the folder `base` (both absolute), when they share a root. +fn relative(base: &Path, target: &Path) -> Option { + let (b, t): (Vec<_>, Vec<_>) = (base.components().collect(), target.components().collect()); + let root = |c: &[PathPart]| c.iter().take_while(|x| matches!(x, PathPart::Prefix(_) | PathPart::RootDir)).count(); + if root(&b) == 0 || b.get(..root(&b)) != t.get(..root(&b)) { + return None; + } + let common = b.iter().zip(&t).take_while(|(x, y)| x == y).count(); + let mut out = PathBuf::new(); + for _ in common..b.len() { + out.push(".."); + } + for c in t.iter().skip(common) { + out.push(c.as_os_str()); + } + Some(out) +} + +/// How a link stores `file` for a design saved at `design` (`None`: not saved yet). +fn stored(design: Option<&str>, file: &Path) -> String { + let file = absolute(file); + match base_dir(design).and_then(|d| relative(&d, &file)) { + // Relative paths are written with `/`, so a design moves between systems with its parts. + Some(rel) => rel.components().map(|c| c.as_os_str().to_string_lossy().into_owned()).collect::>().join("/"), + None => file.to_string_lossy().into_owned(), + } +} + +/// The file a stored link path names, for a design saved at `design`. +fn resolve(design: Option<&str>, link: &str) -> PathBuf { + let p = Path::new(link); + match base_dir(design) { + Some(d) if p.is_relative() => d.join(p), + _ => p.to_path_buf(), + } +} + +/// The links of `doc` stored for a design saved at `to` instead of at `from`. +pub(super) fn rebased(doc: &Document, from: Option<&str>, to: &str) -> std::collections::BTreeMap { + doc.links.iter().map(|(c, l)| (*c, DesignLink { path: stored(Some(to), &resolve(from, &l.path)), feature: l.feature })).collect() +} + +/// The solid bodies of the design at `file`, evaluated, as an Import feature. +fn linked_bodies(file: &Path, own: Option<&str>) -> Result<(String, solidcraft_io::StepFeature)> { + let shown = file.display().to_string(); + if own.is_some_and(|o| absolute(Path::new(o)) == absolute(file)) { + return Err(EngineError::Other(format!("{shown}: a design can't be linked into itself"))); + } + let bytes = solidcraft_io::vfs::read(&shown, solidcraft_io::MAX_DESIGN_BYTES).map_err(|e| EngineError::Other(format!("{shown}: {e}")))?; + let doc = solidcraft_io::read_design(&bytes).map_err(|e| EngineError::Other(format!("{shown}: {e}")))?; + let name = doc.name.clone(); + let part = Session::new(doc); + if let Some(e) = part.model.results.iter().find_map(|r| r.error.clone()) { + return Err(EngineError::Other(format!("{shown}: the design has a feature that fails ({e}); fix it before linking it"))); + } + let world = part.world_state(); + let solids: Vec = world.bodies.iter().filter(|b| !b.body.is_mesh()).map(|b| b.name.clone()).collect(); + if solids.is_empty() { + return Err(EngineError::Other(format!("{shown}: the design has no solid bodies to link"))); + } + let step = solidcraft_io::export(&part.doc.painted(&world), &solids, solidcraft_io::Format::Step, &name) + .map_err(|e| EngineError::Other(format!("{shown}: {e}")))?; + let mut feature = solidcraft_io::step_import_feature(&step, &shown).map_err(|e| EngineError::Other(format!("{shown}: {e}")))?; + if world.bodies.len() > solids.len() { + feature.warnings.push(format!("{} mesh bodies of {shown} are not linked", world.bodies.len() - solids.len())); + } + Ok((name, feature)) +} + +fn component_key(s: &Session, p: &Value, key: &str, cmd: &str) -> Result { + let k = match p.get(key) { + Some(Value::Number(n)) => n.to_string(), + Some(Value::String(x)) => x.clone(), + _ => return Err(bad(cmd, format!("`{key}` must be a component id or name"))), + }; + s.doc.find_component(&k).ok_or_else(|| bad(cmd, format!("no component `{k}`"))) +} + +fn insert_design(s: &mut Session, p: &Value) -> Result { + let cmd = "component.insert_design"; + let given = str_(p, "path").filter(|x| !x.trim().is_empty() && x.len() < 4096).ok_or_else(|| bad(cmd, "`path` is required"))?; + let translate = match p.get("translate") { + None => solidcraft_geom::Vec3::ZERO, + Some(v) => vec3(v).filter(|t| t.is_finite() && t.len() < 1e9).ok_or_else(|| bad(cmd, "`translate` must be [x, y, z] in mm"))?, + }; + let file = absolute(Path::new(given)); + let (design_name, sf) = linked_bodies(&file, s.path.as_deref())?; + let name = str_(p, "name").filter(|n| !n.trim().is_empty()).map(str::to_string).unwrap_or(design_name); + let mut create = json!({"name": name, "activate": false}); + if let Some(parent) = p.get("parent") { + create["parent"] = parent.clone(); + } + let made = s.execute("component.create", &create)?; + let comp = made["component"].as_u64().ok_or_else(|| bad(cmd, "the component was not created"))?; + let occ = made["occurrence"].as_u64(); + let id = s.doc_mut().add_feature(sf.kind, Some(&name))?; + if let Some(f) = s.doc_mut().feature_mut(id) { + f.component = comp; + f.body_names = sf.body_names; + } + if let Some(o) = s.doc_mut().occurrences.iter_mut().find(|o| Some(o.id) == occ) { + o.transform[3] = [translate.x, translate.y, translate.z, 1.0]; + } + let path = stored(s.path.as_deref(), &file); + s.doc_mut().links.insert(comp, DesignLink { path: path.clone(), feature: id }); + 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(); + Ok( + json!({"component": comp, "occurrence": occ, "name": name, "feature": id, "path": path, "bodies": bodies, "created_bodies": bodies, "warnings": sf.warnings}), + ) +} + +fn update_links(s: &mut Session, p: &Value) -> Result { + let cmd = "component.update_links"; + let only = if p.get("component").is_some() { Some(component_key(s, p, "component", cmd)?) } else { None }; + if let Some(c) = only + && !s.doc.links.contains_key(&c) + { + return Err(bad(cmd, "that component is not linked to a design")); + } + let links: Vec<(u64, DesignLink)> = s.doc.links.iter().filter(|(c, _)| only.is_none_or(|o| o == **c)).map(|(c, l)| (*c, l.clone())).collect(); + let (mut updated, mut unchanged, mut warnings) = (Vec::new(), Vec::new(), Vec::new()); + for (comp, link) in links { + let cname = s.doc.components.iter().find(|c| c.id == comp).map(|c| c.name.clone()); + let Some(cname) = cname else { + // The component was deleted: the link goes with it. + s.doc_mut().links.remove(&comp); + continue; + }; + let Some(FeatureKind::Import { step: old, .. }) = s.doc.feature(link.feature).filter(|f| f.component == comp).map(|f| &f.kind) else { + return Err(bad( + cmd, + format!("{cname}: its linked bodies (feature {}) are gone; break the link or insert the design again", link.feature), + )); + }; + let old = old.clone(); + let file = resolve(s.path.as_deref(), &link.path); + let (_, sf) = linked_bodies(&file, s.path.as_deref()).map_err(|e| bad(cmd, format!("{cname}: {e}")))?; + warnings.extend(sf.warnings.iter().cloned()); + if matches!(&sf.kind, FeatureKind::Import { step, .. } if *step == old) { + unchanged.push(cname); + continue; + } + if let Some(f) = s.doc_mut().feature_mut(link.feature) { + // Bodies keep their names where the design still has as many. + if f.body_names.len() != sf.body_names.len() { + f.body_names = sf.body_names; + } + f.kind = sf.kind; + } + updated.push(cname); + } + s.refresh(); + if let Some(e) = s.doc.links.values().find_map(|l| s.model.result(l.feature).and_then(|r| r.error.clone())) { + return Err(EngineError::Other(e)); + } + Ok(json!({"updated": updated, "unchanged": unchanged, "warnings": warnings})) +} + +fn break_link(s: &mut Session, p: &Value) -> Result { + let cmd = "component.break_link"; + let comp = component_key(s, p, "component", cmd)?; + let link = s.doc_mut().links.remove(&comp).ok_or_else(|| bad(cmd, "that component is not linked to a design"))?; + Ok(json!({"component": comp, "path": link.path})) +} + +fn list_links(s: &mut Session, _p: &Value) -> Result { + let links: Vec = s + .doc + .links + .iter() + .map(|(c, l)| { + let file = resolve(s.path.as_deref(), &l.path); + let name = s.doc.components.iter().find(|x| x.id == *c).map(|x| x.name.clone()); + json!({"component": c, "name": name, "path": l.path, "file": file.display().to_string(), "found": file.is_file()}) + }) + .collect(); + Ok(json!({"links": links})) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + + #[test] + fn link_paths_are_kept_relative_to_the_design_and_follow_save_as() { + let root = absolute(Path::new("projects")); + let part = root.join("parts").join("plate.solidcraft"); + let design = root.join("assembly").join("case.solidcraft"); + let design = design.to_string_lossy().into_owned(); + assert_eq!(stored(Some(&design), &part), "../parts/plate.solidcraft"); + assert_eq!(absolute(&resolve(Some(&design), "../parts/plate.solidcraft")), part); + // A design path given relative to the working folder works the same. + assert_eq!(stored(Some("case.solidcraft"), &absolute(Path::new("parts/plate.solidcraft"))), "parts/plate.solidcraft"); + // Before the design has a file the path is kept whole; saving it makes it relative. + let unsaved = stored(None, &part); + assert!(Path::new(&unsaved).is_absolute(), "{unsaved}"); + let mut doc = Document::default(); + doc.links.insert(7, DesignLink { path: unsaved, feature: 3 }); + let moved = rebased(&doc, None, &design); + assert_eq!(moved.get(&7).map(|l| l.path.as_str()), Some("../parts/plate.solidcraft")); + let beside = root.join("parts").join("asm.solidcraft").to_string_lossy().into_owned(); + assert_eq!( + rebased(&Document { links: moved, ..Document::default() }, Some(&design), &beside).get(&7).map(|l| l.path.as_str()), + Some("plate.solidcraft") + ); + } + + fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) + } + + fn save_box(path: &Path, height: f64) { + let mut part = Session::default(); + run(&mut part, "solid.box", json!({"length": 10, "width": 20, "height": height, "body_name": "Plate"})); + run(&mut part, "file.save", json!({"path": path})); + } + + fn volume(s: &mut Session) -> f64 { + run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN) + } + + #[test] + fn a_saved_assembly_reopens_with_its_link_updates_it_and_breaks_it() { + let dir = std::env::temp_dir().join(format!("solidcraft-link-reopen-{}", std::process::id())); + std::fs::create_dir_all(dir.join("parts")).unwrap(); + std::fs::create_dir_all(dir.join("asm")).unwrap(); + let part = dir.join("parts").join("plate.solidcraft"); + let case = dir.join("asm").join("case.solidcraft"); + save_box(&part, 30.0); + // Given an assembly holding the part, saved in a folder beside the part's. + let mut a = Session::default(); + let inserted = run(&mut a, "component.insert_design", json!({"path": part})); + assert_eq!(inserted["name"], "plate"); + run(&mut a, "file.save", json!({"path": case})); + assert!((volume(&mut a) - 6000.0).abs() < 1e-6); + // When it is opened again, the link is kept relative to it and finds the part. + let mut b = Session::default(); + run(&mut b, "doc.open", json!({"path": case})); + let links = run(&mut b, "component.links", json!({})); + assert_eq!(links["links"][0]["path"], "../parts/plate.solidcraft", "{links}"); + assert_eq!(links["links"][0]["found"], true, "{links}"); + assert!((volume(&mut b) - 6000.0).abs() < 1e-6); + // An unchanged part leaves the assembly as it is; an edited one comes in on update. + assert_eq!(run(&mut b, "component.update_links", json!({}))["unchanged"], json!(["plate"])); + save_box(&part, 45.0); + assert_eq!(run(&mut b, "component.update_links", json!({}))["updated"], json!(["plate"])); + assert!((volume(&mut b) - 9000.0).abs() < 1e-6, "{}", volume(&mut b)); + // A part file that has gone is reported, and the assembly keeps the bodies it had. + std::fs::remove_file(&part).unwrap(); + let err = b.execute("component.update_links", &json!({})).unwrap_err().to_string(); + assert!(err.contains("plate"), "{err}"); + assert!((volume(&mut b) - 9000.0).abs() < 1e-6); + // Break Link keeps the bodies and forgets the file. + run(&mut b, "component.break_link", json!({"component": "plate"})); + assert_eq!(run(&mut b, "component.links", json!({}))["links"], json!([])); + assert!((volume(&mut b) - 9000.0).abs() < 1e-6); + assert!(b.execute("component.update_links", &json!({"component": "plate"})).is_err()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn inserting_a_missing_file_or_bad_parameters_changes_nothing() { + let mut s = Session::default(); + let missing = std::env::temp_dir().join(format!("solidcraft-no-such-part-{}.solidcraft", std::process::id())); + assert!(s.execute("component.insert_design", &json!({"path": missing})).is_err()); + assert!(s.execute("component.insert_design", &json!({})).is_err()); + assert!(s.execute("component.insert_design", &json!({"path": "x.solidcraft", "translate": [1, "a", 2]})).is_err()); + assert!(s.doc.components.is_empty() && s.doc.links.is_empty() && s.doc.features.is_empty()); + } +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 0657aacb..e9e3cc9e 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -190,6 +190,26 @@ fn write_file(s: &mut Session, path: &str, format: &str, compression: &str, prev "compact" => solidcraft_io::DesignCompression::Compact, _ => return Err(bad("file.save", "compression must be fast or compact")), }; + // Linked designs are kept relative to the folder the design is saved in. + let links = super::component_link::rebased(&s.doc, s.path.as_deref(), path); + let old_links = (links != s.doc.links).then(|| std::mem::replace(&mut s.doc_mut().links, links)); + let r = write_bytes(s, path, format, compression, preview, cache); + if r.is_err() + && let Some(old) = old_links + { + s.doc_mut().links = old; + } + r +} + +fn write_bytes( + s: &mut Session, + path: &str, + format: &str, + compression: solidcraft_io::DesignCompression, + preview: bool, + cache: bool, +) -> Result { let bytes = match format { "container" => s.design_container(compression, preview, cache)?, "json" => solidcraft_io::write_design(&s.design()), diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index ed4447b4..22b9d9e0 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -22,6 +22,7 @@ mod face; mod features; mod features_more; pub use features::auto_operation; +mod component_link; mod config; mod file; mod inspect; @@ -204,6 +205,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> { v.extend(sheet::COMMANDS.iter()); v.extend(plastic::COMMANDS.iter()); v.extend(component::COMMANDS.iter()); + v.extend(component_link::COMMANDS.iter()); v.extend(joints::COMMANDS.iter()); v.extend(motion::COMMANDS.iter()); v.extend(config::COMMANDS.iter()); diff --git a/crates/engine/src/cmd/occurrence_tests.rs b/crates/engine/src/cmd/occurrence_tests.rs index 6c25084a..b83be778 100644 --- a/crates/engine/src/cmd/occurrence_tests.rs +++ b/crates/engine/src/cmd/occurrence_tests.rs @@ -517,7 +517,6 @@ fn saved_box_design(path: &std::path::Path, height: f64) { } #[test] -#[ignore = "fails until #533 is fixed"] fn a_linked_design_brings_its_bodies_in_and_follows_edits_to_the_part_file() { let dir = std::env::temp_dir().join(format!("solidcraft-linked-design-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); diff --git a/docs/book/src/guide/assemblies.md b/docs/book/src/guide/assemblies.md index fbc38a6a..ffd56919 100644 --- a/docs/book/src/guide/assemblies.md +++ b/docs/book/src/guide/assemblies.md @@ -26,6 +26,16 @@ couples two joints. Contact sets stop motion where bodies would pass through eac **Motion Study** keys joint values along a timeline, and **Interference** shows overlapping volumes in red. +**Insert Design** (`component.insert_design {path, translate?}`) adds a component linked to +another saved design, so each part can live in its own document. The part's solid bodies come +in as they sit in that design, and they are kept in the assembly, so it opens even without the +part file. The link is stored relative to the assembly's folder once the assembly is saved. +After the part file changes, **Update Links** (`component.update_links`) reads it again. +**Break Link** (`component.break_link`) keeps the bodies and forgets the file, and +`component.links` lists the links and whether their files are found. A linked component holds +the part's bodies only: its timeline, parameters and sheet-metal data (flat pattern, bend +table) stay in the part's own design. + STEP export writes the component tree as an assembly. Right-click a component in the Browser and choose **Properties** to inspect