component.insert_design (a saved part as a linked component) had no button, so an assembly of separately saved parts looked impossible. It is now on the ASSEMBLE panel of every tab and in the File menu; it asks for the file, and a STEP or mesh file picked there is inserted as Insert STEP or Insert Mesh would. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
330 lines
16 KiB
Rust
330 lines
16 KiB
Rust
//! 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).at("SOLID", "ASSEMBLE").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());
|
|
}
|
|
}
|