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
This commit is contained in:
Jonas Wrede authored and GitHub committed 2026-10-13 06:40:58 -04:00
1 parent 90869732a3
commit 46e21127dd
7 files changed
+380 -1

No files matched your search

+16
View File
@@ -1698,6 +1698,18 @@ pub struct StandardPart {
pub length: Option<f64>,
}
/// 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<u64, StandardPart>,
/// Components linked to another design file (by component id).
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub links: std::collections::BTreeMap<u64, DesignLink>,
/// 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(),
+3
View File
@@ -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.
+329
View File
@@ -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<PathBuf> {
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<PathBuf> {
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::<Vec<_>>().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<u64, DesignLink> {
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<String> = 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<u64> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
let links: Vec<Value> = 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());
}
}
+20
View File
@@ -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<Value> {
let bytes = match format {
"container" => s.design_container(compression, preview, cache)?,
"json" => solidcraft_io::write_design(&s.design()),
+2
View File
@@ -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());
@@ -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();
+10
View File
@@ -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