* 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 <noreply@anthropic.com> * Keep an import's rigid group hidden when the window loads the file in the background Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Import instances' skip list is format 22: format 21 ships in 0.12.0 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Catalog entries for Unlock Contents Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d43c414b7b
commit
27529d9742
37 files changed
+968
-103
No files matched your search
@@ -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<Vec<f64>> = plain.iter().chain(&LIFTED).chain(&LIFTED).map(|c| c.to_vec()).collect();
|
||||
let at: Vec<Vec<f64>> = 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
|
||||
|
||||
@@ -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<Value> {
|
||||
Ok(json!({"deleted": gone, "features": feats}))
|
||||
}
|
||||
|
||||
fn unlock(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
transfer::move_bodies(s, p)
|
||||
}
|
||||
@@ -376,6 +397,17 @@ fn move_occurrence(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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") {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()];
|
||||
|
||||
@@ -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<solidcraft_i
|
||||
solidcraft_io::step_import_feature_named(&bytes, path, names).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
/// Turn bodies a quarter turn about X (a file's Y up becomes Z up) with one Move feature, the
|
||||
/// same one the app's "Stand it up" offer adds, and report its id.
|
||||
fn stand_up(s: &mut Session, bodies: &[String]) -> Result<u64> {
|
||||
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<Value> {
|
||||
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<String> = 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<String> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
@@ -193,10 +188,21 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} 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) {
|
||||
|
||||
@@ -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<usize>,
|
||||
transform: Mat,
|
||||
}
|
||||
|
||||
/// How a STEP file's tree becomes components.
|
||||
#[derive(Default)]
|
||||
struct Plan {
|
||||
slots: Vec<Slot>,
|
||||
places: Vec<Place>,
|
||||
/// 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<u64>,
|
||||
}
|
||||
|
||||
/// 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<u64, usize> = 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<usize>, parent_world: &Mat, seen: &mut HashMap<u64, usize>, 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<usize>, 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<u64>, 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<u64>,
|
||||
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<u64>,
|
||||
}
|
||||
|
||||
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<u64>)> {
|
||||
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<String>,
|
||||
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<u64> = Vec::with_capacity(plan.slots.len());
|
||||
let mut first_occ: Vec<Option<u64>> = 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<String> = s.model.state().bodies.iter().filter(|b| b.feature == id).map(|b| b.name.clone()).collect();
|
||||
let forecast: Vec<String> = names.iter().flatten().cloned().collect();
|
||||
if built != forecast {
|
||||
let mut it = built.into_iter();
|
||||
let actual: Vec<Option<String>> = 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<String>], 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<Vec<u64>> {
|
||||
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<Joint> = 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<u64> {
|
||||
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<u64> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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!({}));
|
||||
|
||||
@@ -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<Value> {
|
||||
run(s, "component.list", json!({}))["components"].as_array().unwrap().clone()
|
||||
}
|
||||
|
||||
fn component<'a>(list: &'a [Value], name: &str) -> &'a Value {
|
||||
list.iter().find(|c| c["name"] == name).unwrap_or_else(|| panic!("no component {name}: {list:?}"))
|
||||
}
|
||||
|
||||
fn occurrence_named(s: &Session, name: &str) -> u64 {
|
||||
s.doc.occurrences.iter().find(|o| o.name == name).unwrap_or_else(|| panic!("no occurrence {name}")).id
|
||||
}
|
||||
|
||||
/// The world corner of the bodies of one occurrence (by name) nearest -∞.
|
||||
fn corner_of(s: &Session, occurrence: &str) -> Vec3 {
|
||||
let occ = occurrence_named(s, occurrence);
|
||||
let path = {
|
||||
let mut path = vec![occ];
|
||||
let mut parent = s.doc.occurrences.iter().find(|o| o.id == occ).unwrap().parent;
|
||||
while parent != 0 {
|
||||
let o = s.doc.occurrence_of(parent).unwrap();
|
||||
path.insert(0, o.id);
|
||||
parent = o.parent;
|
||||
}
|
||||
path
|
||||
};
|
||||
let names = s.occurrence_bodies(&path, true).unwrap();
|
||||
assert!(!names.is_empty());
|
||||
let world = s.world_state();
|
||||
let mut lo = Vec3::new(f64::MAX, f64::MAX, f64::MAX);
|
||||
for b in world.bodies.iter().filter(|b| names.contains(&b.name)) {
|
||||
let bb = solidcraft_kernel::measure(&b.body).unwrap().bbox;
|
||||
lo = Vec3::new(lo.x.min(bb.min.x), lo.y.min(bb.min.y), lo.z.min(bb.min.z));
|
||||
}
|
||||
lo
|
||||
}
|
||||
|
||||
fn close(a: Vec3, b: Vec3) -> bool {
|
||||
a.dist(b) < 1e-6
|
||||
}
|
||||
|
||||
/// A box exported as `name.step` in `dir`.
|
||||
fn part_file(dir: &std::path::Path, name: &str) -> String {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 30, "width": 20, "height": 10}));
|
||||
let path = dir.join(format!("{name}.step")).to_string_lossy().to_string();
|
||||
run(&mut s, "file.export", json!({"path": path}));
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_step_part_comes_in_as_one_component_named_after_the_file() {
|
||||
let dir = temp_dir("part");
|
||||
let path = part_file(&dir, "bracket");
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [100, 0, 0]}));
|
||||
let r = run(&mut s, "file.insert_step", json!({"path": path}));
|
||||
let list = components(&mut s);
|
||||
assert_eq!(list.len(), 2, "the design and the import: {list:?}");
|
||||
let bracket = component(&list, "bracket");
|
||||
assert_eq!((r["component"].clone(), r["component_name"].clone()), (bracket["id"].clone(), json!("bracket")), "{r}");
|
||||
assert_eq!(bracket["bodies"].as_array().unwrap().len(), 1, "{bracket}");
|
||||
assert_eq!(component(&list, &s.doc.name.clone())["bodies"].as_array().unwrap().len(), 1, "my own box stays where it was");
|
||||
let feature = r["feature"].as_u64().unwrap();
|
||||
assert_eq!(s.doc.feature(feature).unwrap().component, r["component"].as_u64().unwrap());
|
||||
// One part: nothing to hold together.
|
||||
assert!(s.doc.assembly.joints.is_empty() && r.get("subcomponents").is_none(), "{r}");
|
||||
// The active component is still the one I was working in.
|
||||
assert_eq!(s.active_component, 0);
|
||||
|
||||
// Opening the file as a design makes the component too, so the browser shows its name.
|
||||
let r = run(&mut s, "doc.open", json!({"path": path}));
|
||||
assert_eq!(r["component_name"], "bracket", "{r}");
|
||||
assert_eq!(s.doc.components.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(), ["bracket"]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_step_assembly_comes_in_as_nested_components_with_its_instances() {
|
||||
let dir = temp_dir("assembly");
|
||||
let path = rig_file(&dir);
|
||||
let mut s = Session::default();
|
||||
let r = run(&mut s, "file.insert_step", json!({"path": path}));
|
||||
// rig ⊃ Base, Arm (twice; Beam, Pin inside), Pin (once more, directly).
|
||||
let list = components(&mut s);
|
||||
let names: Vec<&str> = list.iter().filter_map(|c| c["name"].as_str()).collect();
|
||||
assert_eq!(names.len(), 6, "{names:?}");
|
||||
for n in ["rig", "Base", "Arm", "Beam", "Pin"] {
|
||||
assert!(names.contains(&n), "{n} in {names:?}");
|
||||
}
|
||||
let id = |n: &str| component(&list, n)["id"].as_u64().unwrap();
|
||||
let parent = |n: &str| component(&list, n)["parent"].as_u64().unwrap();
|
||||
assert_eq!(parent("rig"), 0);
|
||||
assert_eq!((parent("Base"), parent("Arm")), (id("rig"), id("rig")));
|
||||
assert_eq!((parent("Beam"), parent("Pin")), (id("Arm"), id("Arm")));
|
||||
assert_eq!(r["component"], id("rig"), "{r}");
|
||||
assert_eq!(r["subcomponents"], 4, "{r}");
|
||||
// A repeated part is one component placed again: Arm twice, Pin in the arm and on its own.
|
||||
let placements = |c: u64| s.doc.occurrences.iter().filter(|o| o.component == c).count();
|
||||
assert_eq!((placements(id("Arm")), placements(id("Pin")), placements(id("Beam"))), (2, 2, 1));
|
||||
// Each component holds its own part's body; the file's later instances are not built again.
|
||||
let own = |n: &str| component(&list, n)["bodies"].as_array().unwrap().len();
|
||||
assert_eq!((own("rig"), own("Base"), own("Arm"), own("Beam"), own("Pin")), (0, 1, 0, 1, 1));
|
||||
assert_eq!(s.model.state().bodies.len(), 3, "base, beam and pin, once each");
|
||||
// And every body shows where the file puts it.
|
||||
assert_eq!(shown_boxes(&s), file_boxes(&path));
|
||||
|
||||
// Saved and opened again, it is the same.
|
||||
let design = dir.join("rig.solidcraft").to_string_lossy().to_string();
|
||||
run(&mut s, "file.save_as", json!({"path": design}));
|
||||
let mut again = Session::default();
|
||||
run(&mut again, "doc.open", json!({"path": design}));
|
||||
assert_eq!(shown_boxes(&again), file_boxes(&path));
|
||||
assert_eq!(again.doc.components.len(), 5);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Each occurrence's degrees of freedom, as `joint.list` gives them.
|
||||
fn dofs(s: &mut Session) -> (std::collections::BTreeMap<u64, u64>, Value) {
|
||||
let r = run(s, "joint.list", json!({}));
|
||||
let dof = r["dof"].as_array().unwrap().iter().map(|d| (d["occurrence"].as_u64().unwrap(), d["dof"].as_u64().unwrap())).collect();
|
||||
(dof, r)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_imported_assembly_moves_as_one_until_it_is_unlocked() {
|
||||
let dir = temp_dir("rigid");
|
||||
let path = rig_file(&dir);
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "file.insert_step", json!({"path": path}));
|
||||
let rig = occurrence_named(&s, "rig:1");
|
||||
let inside: Vec<u64> = s.doc.occurrences.iter().filter(|o| o.id != rig).map(|o| o.id).collect();
|
||||
assert_eq!(inside.len(), 6);
|
||||
let pin = corner_of(&s, "Pin:2");
|
||||
let arm = corner_of(&s, "Arm:1");
|
||||
|
||||
// The whole import is one rigid body: no part of it moves against the others.
|
||||
let (dof, r) = dofs(&mut s);
|
||||
assert!(r["conflicts"].as_array().unwrap().is_empty(), "{r}");
|
||||
for o in &inside {
|
||||
assert_eq!(dof.get(o), dof.get(&rig), "occurrence {o} moves only with the import: {r}");
|
||||
}
|
||||
|
||||
// Moving the import's component moves everything in it.
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": "rig:1", "translate": [0, 0, 50]}));
|
||||
assert!(close(corner_of(&s, "Pin:2"), pin + Vec3::new(0.0, 0.0, 50.0)), "{:?}", corner_of(&s, "Pin:2"));
|
||||
assert!(close(corner_of(&s, "Arm:1"), arm + Vec3::new(0.0, 0.0, 50.0)));
|
||||
// A part inside doesn't move on its own; the refusal says what to move instead.
|
||||
for part in ["Pin:2", "Pin:1"] {
|
||||
let e = s.execute("occurrence.move", &json!({"occurrence": part, "translate": [30, 0, 0]})).unwrap_err().to_string();
|
||||
assert!(e.contains("move rig"), "{e}");
|
||||
}
|
||||
assert!(close(corner_of(&s, "Pin:2"), pin + Vec3::new(0.0, 0.0, 50.0)));
|
||||
|
||||
// Next to a grounded part of mine the import is still one rigid body.
|
||||
run(&mut s, "component.create", json!({"name": "Mine"}));
|
||||
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [-50, 0, 0]}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "occurrence.ground", json!({"occurrence": "Mine:1", "grounded": true}));
|
||||
let (dof, r) = dofs(&mut s);
|
||||
for o in &inside {
|
||||
assert_eq!(dof.get(o), dof.get(&rig), "{r}");
|
||||
}
|
||||
assert!(r["conflicts"].as_array().unwrap().is_empty(), "{r}");
|
||||
|
||||
// Unlocked (one undo step), the parts move one by one.
|
||||
let undo = s.undo.len();
|
||||
let r = run(&mut s, "component.unlock", json!({"component": "rig"}));
|
||||
assert_eq!(r["joints"].as_array().unwrap().len(), 6, "{r}");
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": "Pin:2", "translate": [30, 0, 0]}));
|
||||
assert!(close(corner_of(&s, "Pin:2"), pin + Vec3::new(30.0, 0.0, 50.0)), "{:?}", corner_of(&s, "Pin:2"));
|
||||
assert!(close(corner_of(&s, "Arm:1"), arm + Vec3::new(0.0, 0.0, 50.0)), "the others stay");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.assembly.joints.len(), 6, "undo locks it again");
|
||||
assert!(s.execute("component.unlock", &json!({"component": "Mine"})).is_err(), "nothing holds my own component");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mesh_file_comes_in_as_one_component() {
|
||||
let dir = temp_dir("stl");
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
|
||||
let stl = dir.join("cube.stl").to_string_lossy().to_string();
|
||||
run(&mut s, "file.save_mesh", json!({"path": stl}));
|
||||
let mut s = Session::default();
|
||||
let r = run(&mut s, "file.insert_mesh", json!({"path": stl}));
|
||||
assert_eq!(r["component_name"], "cube", "{r}");
|
||||
let list = components(&mut s);
|
||||
assert_eq!(list.len(), 2);
|
||||
assert_eq!(component(&list, "cube")["bodies"].as_array().unwrap().len(), 1);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_takes_the_whole_import_away() {
|
||||
let dir = temp_dir("undo");
|
||||
let path = rig_file(&dir);
|
||||
let mut s = Session::default();
|
||||
let undo = s.undo.len();
|
||||
run(&mut s, "file.insert_step", json!({"path": path}));
|
||||
assert_eq!(s.undo.len(), undo + 1, "one step");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc.features.is_empty() && s.doc.components.is_empty() && s.doc.occurrences.is_empty(), "nothing left");
|
||||
assert!(s.doc.assembly.joints.is_empty() && s.doc.body_components.is_empty() && s.doc.body_offsets.is_empty());
|
||||
assert!(s.world_state().bodies.is_empty());
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(shown_boxes(&s), file_boxes(&path));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A design saved before imports made components has its Import loose in the design: it opens
|
||||
/// as it is (no component is made for it) and builds every body of the file where it was.
|
||||
#[test]
|
||||
fn a_design_with_a_loose_import_rebuilds_unchanged() {
|
||||
let dir = temp_dir("loose");
|
||||
let path = rig_file(&dir);
|
||||
let sf = solidcraft_io::step_import_feature(&std::fs::read(&path).unwrap(), &path).unwrap();
|
||||
let mut doc = solidcraft_doc::Document::new("Old");
|
||||
let id = doc.add_feature(sf.kind, Some(&sf.name)).unwrap();
|
||||
doc.feature_mut(id).unwrap().body_names = sf.body_names;
|
||||
// As a format 21 file (0.12.0) has it.
|
||||
let mut json: Value = serde_json::from_str(&doc.to_json()).unwrap();
|
||||
json["format"] = json!("solidcraft/21");
|
||||
assert!(json["features"][0]["kind"].get("skip").is_none());
|
||||
let file = dir.join("old.solidcraft");
|
||||
std::fs::write(&file, json.to_string()).unwrap();
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "doc.open", json!({"path": file.to_string_lossy()}));
|
||||
assert!(s.doc.components.is_empty() && s.doc.occurrences.is_empty(), "not migrated");
|
||||
assert_eq!(s.doc.features[0].component, 0);
|
||||
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
|
||||
assert_eq!(names, ["Base", "Beam", "Pin", "Beam (2)", "Pin (2)", "Pin (3)"]);
|
||||
assert_eq!(shown_boxes(&s), file_boxes(&path));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -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"]);
|
||||
}
|
||||
Reference in new issue
Block a user