diff --git a/crates/doc/src/assembly.rs b/crates/doc/src/assembly.rs new file mode 100644 index 00000000..c6dce9c8 --- /dev/null +++ b/crates/doc/src/assembly.rs @@ -0,0 +1,285 @@ +//! Components and occurrences: the design as a tree. +//! +//! The root component (id 0) is the design. Every other component lives in a parent component +//! through one or more occurrences, each with a rigid transform (instances share the component's +//! contents; a "Paste New" copy is a separate component). A component's sketches, construction +//! geometry and features are authored in its own frame (its origin); the model is evaluated in +//! those frames and placed in the world through the occurrence transforms when bodies are +//! gathered for display, measuring and export (`world_bodies`). Commands take world coordinates +//! and the engine maps a new feature's geometry into the active component's frame. + +use serde::{Deserialize, Serialize}; +use solvecraft_geom::{Plane, Vec3}; + +use crate::{AxisRef, DocError, Document, FeatureKind, PatternKind, PlaneRef, Result}; + +/// Column-major 4×4 affine transform (`m[col][row]`). +pub type Mat = [[f64; 4]; 4]; + +pub const IDENTITY: Mat = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]; + +pub fn mat_mul(a: &Mat, b: &Mat) -> Mat { + let mut r = [[0.0; 4]; 4]; + for (c, col) in r.iter_mut().enumerate() { + for (row, cell) in col.iter_mut().enumerate() { + *cell = (0..4).map(|k| a[k][row] * b[c][k]).sum(); + } + } + r +} + +pub fn apply_point(m: &Mat, p: Vec3) -> Vec3 { + Vec3::new( + m[0][0] * p.x + m[1][0] * p.y + m[2][0] * p.z + m[3][0], + m[0][1] * p.x + m[1][1] * p.y + m[2][1] * p.z + m[3][1], + m[0][2] * p.x + m[1][2] * p.y + m[2][2] * p.z + m[3][2], + ) +} + +pub fn apply_vector(m: &Mat, v: Vec3) -> Vec3 { + Vec3::new( + m[0][0] * v.x + m[1][0] * v.y + m[2][0] * v.z, + m[0][1] * v.x + m[1][1] * v.y + m[2][1] * v.z, + m[0][2] * v.x + m[1][2] * v.y + m[2][2] * v.z, + ) +} + +pub fn is_identity(m: &Mat) -> bool { + m.iter().flatten().zip(IDENTITY.iter().flatten()).all(|(a, b)| (a - b).abs() < 1e-12) +} + +/// Inverse of an affine transform. +pub fn mat_inverse(m: &Mat) -> Option { + let a = |r: usize, c: usize| m[c][r]; + let det = a(0, 0) * (a(1, 1) * a(2, 2) - a(1, 2) * a(2, 1)) - a(0, 1) * (a(1, 0) * a(2, 2) - a(1, 2) * a(2, 0)) + + a(0, 2) * (a(1, 0) * a(2, 1) - a(1, 1) * a(2, 0)); + if det.abs() < 1e-14 || !det.is_finite() { + return None; + } + let inv = |r: usize, c: usize| -> f64 { + // Cofactor of (c, r) / det. + let (r0, r1) = match c { + 0 => (1, 2), + 1 => (0, 2), + _ => (0, 1), + }; + let (c0, c1) = match r { + 0 => (1, 2), + 1 => (0, 2), + _ => (0, 1), + }; + let minor = a(r0, c0) * a(r1, c1) - a(r0, c1) * a(r1, c0); + let sign = if (r + c) % 2 == 0 { 1.0 } else { -1.0 }; + sign * minor / det + }; + let mut out = IDENTITY; + for r in 0..3 { + for c in 0..3 { + out[c][r] = inv(r, c); + } + } + let t = Vec3::new(m[3][0], m[3][1], m[3][2]); + let it = apply_vector(&out, t); + out[3] = [-it.x, -it.y, -it.z, 1.0]; + Some(out) +} + +/// A rigid transform: rotation by `angle` about `axis` through `origin`, then a translation. +pub fn rigid(translate: Vec3, origin: Vec3, axis: Vec3, angle: f64) -> Mat { + let k = axis.normalized().unwrap_or(Vec3::Z); + let (s, c) = angle.sin_cos(); + let t = 1.0 - c; + let r = [ + [t * k.x * k.x + c, t * k.x * k.y + s * k.z, t * k.x * k.z - s * k.y], + [t * k.x * k.y - s * k.z, t * k.y * k.y + c, t * k.y * k.z + s * k.x], + [t * k.x * k.z + s * k.y, t * k.y * k.z - s * k.x, t * k.z * k.z + c], + ]; + let mut m = IDENTITY; + for col in 0..3 { + for row in 0..3 { + m[col][row] = r[col][row]; + } + } + let ro = apply_vector(&m, origin); + let tr = origin - ro + translate; + m[3] = [tr.x, tr.y, tr.z, 1.0]; + m +} + +/// A placement of a component inside a parent component. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Occurrence { + pub id: u64, + pub component: u64, + /// The component it sits in (0 = the root). + #[serde(default)] + pub parent: u64, + pub name: String, + #[serde(default = "identity")] + pub transform: Mat, + /// Grounded occurrences don't move. + #[serde(default)] + pub grounded: bool, +} + +fn identity() -> Mat { + IDENTITY +} + +fn map_plane(m: &Mat, p: &Plane) -> Plane { + let (o, x, y) = (apply_point(m, p.origin), apply_vector(m, p.x), apply_vector(m, p.y)); + Plane::new(o, x, y).unwrap_or(*p) +} + +fn map_plane_ref(m: &Mat, p: &mut PlaneRef) { + match p { + PlaneRef::Custom { plane } => *plane = map_plane(m, plane), + PlaneRef::Offset { base, .. } => map_plane_ref(m, base), + PlaneRef::AtAngle { base, axis_origin, axis_dir, .. } => { + map_plane_ref(m, base); + *axis_origin = apply_point(m, *axis_origin); + *axis_dir = apply_vector(m, *axis_dir); + } + // Named origin planes and construction planes are the component's own. + PlaneRef::Origin { .. } | PlaneRef::Construction { .. } => {} + } +} + +impl FeatureKind { + /// Map the feature's world geometry (points, directions, explicit planes) by `m`. + pub fn transform_geometry(&mut self, m: &Mat) { + let pt = |p: &mut Vec3| *p = apply_point(m, *p); + let dir = |v: &mut Vec3| *v = apply_vector(m, *v); + match self { + FeatureKind::Sketch { plane, .. } + | FeatureKind::ConstructionPlane { plane } + | FeatureKind::Split { plane, .. } + | FeatureKind::Mirror { plane, .. } => map_plane_ref(m, plane), + FeatureKind::Revolve { axis: AxisRef::Line { origin, dir: d }, .. } => { + pt(origin); + dir(d); + } + FeatureKind::Fillet { edges, .. } | FeatureKind::Chamfer { edges, .. } => edges.iter_mut().for_each(pt), + FeatureKind::Shell { faces, .. } | FeatureKind::OffsetFace { faces, .. } => faces.iter_mut().for_each(pt), + FeatureKind::Draft { faces, neutral, pull, .. } => { + faces.iter_mut().for_each(pt); + map_plane_ref(m, neutral); + dir(pull); + } + FeatureKind::Box { corner, .. } => pt(corner), + FeatureKind::Cylinder { base, axis, .. } => { + pt(base); + dir(axis); + } + FeatureKind::Sphere { center, .. } | FeatureKind::Torus { center, .. } => pt(center), + FeatureKind::Pattern { pattern: PatternKind::Rectangular { dir1, dir2, .. }, .. } => { + dir(dir1); + if let Some(d) = dir2 { + dir(d); + } + } + FeatureKind::Pattern { pattern: PatternKind::Circular { origin, axis, .. }, .. } => { + pt(origin); + dir(axis); + } + FeatureKind::Hole { position, direction, .. } => { + pt(position); + dir(direction); + } + FeatureKind::Thread { face, .. } => pt(face), + FeatureKind::Scale { origin, .. } => pt(origin), + FeatureKind::Move { rotate_axis, .. } => { + if let Some(a) = rotate_axis { + dir(a); + } + } + FeatureKind::Revolve { .. } + | FeatureKind::Extrude { .. } + | FeatureKind::Combine { .. } + | FeatureKind::Loft { .. } + | FeatureKind::Sweep { .. } + | FeatureKind::BoundingSolid { .. } + | FeatureKind::Pipe { .. } + | FeatureKind::Import { .. } + | FeatureKind::MeshImport { .. } => {} + } + } +} + +impl Document { + /// The first occurrence of a component (its placement for authoring). + pub fn occurrence_of(&self, component: u64) -> Option<&crate::Occurrence> { + self.occurrences.iter().find(|o| o.component == component) + } + + /// World transform of a component's frame (through its first occurrence and its parents'). + pub fn component_transform(&self, component: u64) -> Mat { + let mut m = IDENTITY; + let mut c = component; + for _ in 0..1000 { + if c == 0 { + break; + } + let Some(o) = self.occurrence_of(c) else { break }; + m = mat_mul(&o.transform, &m); + c = o.parent; + } + m + } + + /// Every placement of each component: (component, occurrence path ids, world transform). + pub fn placements(&self) -> Vec<(u64, Vec, Mat)> { + let mut out = vec![(0, Vec::new(), IDENTITY)]; + let mut stack: Vec<(u64, Vec, Mat)> = vec![(0, Vec::new(), IDENTITY)]; + while let Some((comp, path, m)) = stack.pop() { + if path.len() > 64 || out.len() > 100_000 { + break; + } + for o in self.occurrences.iter().filter(|o| o.parent == comp) { + let mut p = path.clone(); + p.push(o.id); + let w = mat_mul(&m, &o.transform); + out.push((o.component, p.clone(), w)); + stack.push((o.component, p, w)); + } + } + out + } + + /// Is `a` inside `b` (or `b` itself)? + pub fn component_within(&self, a: u64, b: u64) -> bool { + let mut c = a; + for _ in 0..1000 { + if c == b { + return true; + } + if c == 0 { + return false; + } + match self.components.iter().find(|x| x.id == c) { + Some(x) => c = x.parent, + None => return false, + } + } + false + } + + /// Add an occurrence of an existing component into `parent`. + pub fn add_occurrence(&mut self, component: u64, parent: u64, transform: Mat) -> Result { + if component == 0 || !self.components.iter().any(|c| c.id == component) { + return Err(DocError::Unknown(format!("component {component}"))); + } + if self.component_within(parent, component) { + return Err(DocError::Invalid("a component can't contain itself".into())); + } + if self.occurrences.len() >= 100_000 { + return Err(DocError::Invalid("too many occurrences".into())); + } + let id = self.next_id.max(1); + self.next_id = id + 1; + let cname = self.components.iter().find(|c| c.id == component).map(|c| c.name.clone()).unwrap_or_default(); + let k = self.occurrences.iter().filter(|o| o.component == component).count() + 1; + self.occurrences.push(crate::Occurrence { id, component, parent, name: format!("{cname}:{k}"), transform, grounded: false }); + Ok(id) + } +} diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 39d717ca..ef0c951a 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -638,6 +638,9 @@ pub struct Document { /// Components below the root, as a tree (by parent). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub components: Vec, + /// Placements of components in their parents (each non-root component has at least one). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub occurrences: Vec, /// Physical material per body (by body name); others use the default. #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] pub materials: std::collections::BTreeMap, @@ -670,6 +673,7 @@ impl Document { marker: None, next_id: 1, components: Vec::new(), + occurrences: Vec::new(), body_components: Default::default(), materials: Default::default(), } @@ -706,6 +710,7 @@ impl Document { } }; self.components.push(Component { id, name, parent }); + self.add_occurrence(id, parent, crate::IDENTITY)?; Ok(id) } @@ -719,6 +724,13 @@ impl Document { pub fn from_json(s: &str) -> Result { let d: Document = serde_json::from_str(s).map_err(|e| DocError::Invalid(format!("document: {e}")))?; + // Designs from before occurrences: every component gets one, in place. + let mut d = d; + let missing: Vec<(u64, u64)> = + d.components.iter().filter(|c| !d.occurrences.iter().any(|o| o.component == c.id)).map(|c| (c.id, c.parent)).collect(); + for (c, p) in missing { + d.add_occurrence(c, p, crate::IDENTITY)?; + } if d.features.len() > MAX_FEATURES || d.params.len() > MAX_PARAMS { return Err(DocError::Invalid("document too large".into())); } diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index d96acf91..1286869a 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -1443,3 +1443,58 @@ fn unique_body_name(st: &ModelState, base: &str) -> String { } (2..).map(|i| format!("{base} ({i})")).find(|n| st.body(n).is_none()).unwrap_or_else(|| base.to_string()) } + +/// The model placed in the world: each component's bodies, sketches and threads through every +/// occurrence of it (bodies of the first placement keep their names; further instances get the +/// occurrence path in brackets). Without components this is the model itself. +pub fn world_state(doc: &Document, st: &ModelState) -> ModelState { + if doc.occurrences.is_empty() { + return st.clone(); + } + let comp_of_feature = |id: u64| doc.feature(id).map(|f| f.component).unwrap_or(0); + let first_path = |comp: u64| -> Vec { + let mut path = Vec::new(); + let mut c = comp; + for _ in 0..1000 { + if c == 0 { + break; + } + let Some(o) = doc.occurrence_of(c) else { break }; + path.insert(0, o.id); + c = o.parent; + } + path + }; + let mut out = ModelState { body_counter: st.body_counter, ..Default::default() }; + for (comp, path, m) in doc.placements() { + let primary = path == first_path(comp); + let label = || -> String { + path.iter().filter_map(|id| doc.occurrences.iter().find(|o| o.id == *id)).map(|o| o.name.as_str()).collect::>().join("/") + }; + let ident = crate::is_identity(&m); + for b in st.bodies.iter().filter(|b| doc.body_component(&b.name, b.feature) == comp) { + let name = if primary { b.name.clone() } else { format!("{} ({})", b.name, label()) }; + let body = if ident { b.body.clone() } else { kernel::transform_matrix(&b.body, m).unwrap_or_else(|_| b.body.clone()) }; + out.bodies.push(ModelBody::new(name, body, b.feature)); + } + if primary { + for s in st.sketches.iter().filter(|s| comp_of_feature(s.feature) == comp) { + let mut s2 = s.clone(); + if !ident { + let p = &s.plane; + if let Some(np) = Plane::new(crate::apply_point(&m, p.origin), crate::apply_vector(&m, p.x), crate::apply_vector(&m, p.y)) { + s2.plane = np; + } + } + out.sketches.push(s2); + } + for t in st.threads.iter().filter(|t| comp_of_feature(t.feature) == comp) { + let mut t2 = t.clone(); + t2.axis_point = crate::apply_point(&m, t.axis_point); + t2.axis = crate::apply_vector(&m, t.axis); + out.threads.push(t2); + } + } + } + out +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 3cbfdcb4..57303f85 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -5,13 +5,15 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +mod assembly; mod document; mod eval; pub mod expr; pub mod project; +pub use assembly::{IDENTITY, Mat, Occurrence, apply_point, apply_vector, is_identity, mat_inverse, mat_mul, rigid}; pub use document::*; -pub use eval::{FeatureResult, Model, ModelBody, ModelState, SolvedSketch, ThreadInfo, parse_metric_thread}; +pub use eval::{FeatureResult, Model, ModelBody, ModelState, SolvedSketch, ThreadInfo, parse_metric_thread, world_state}; pub use solvecraft_kernel as kernel; pub use solvecraft_sketch as sketch; diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index 58b837f4..0d716277 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -1,11 +1,14 @@ -//! Components: the design as a tree. New sketches and features go into the active component; -//! bodies belong to their feature's component unless moved. (Groundwork for assemblies: -//! occurrences, joints and per-component physical properties build on this.) +//! Components and occurrences: the design as a tree (see `solvecraft_doc` assembly docs). +//! New sketches and features go into the active component, authored in its frame; bodies +//! belong to their feature's component unless moved; occurrences place components (instances +//! share contents, "paste new" copies them) and can be grounded and moved. use serde_json::{Value, json}; +use solvecraft_doc::{FeatureKind, Mat, Occurrence}; +use solvecraft_geom::Vec3; use super::CommandSpec; -use crate::params::{bad, str_, string_list}; +use crate::params::{bad, bool_, num, str_, string_list, vec3}; use crate::{Result, Session}; pub static COMMANDS: &[CommandSpec] = &[ @@ -18,9 +21,21 @@ pub static COMMANDS: &[CommandSpec] = &[ .params("bodies: [names] → one new component per body, named after it"), CommandSpec::new("component.activate", "Activate Component", activate).noundo().params("component: id or name (0 or \"root\" for the design)"), CommandSpec::new("component.list", "List Components", list).noundo(), + 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.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) + .params("component (or occurrence): what to place again; parent?: component (default: the same parent); translate?: [x,y,z]"), + CommandSpec::new("component.paste_new", "Paste New", paste_new).params("component: copied with its features into a new, independent component; translate?: [x,y,z]"), + CommandSpec::new("occurrence.move", "Move Occurrence", move_occurrence) + .params("occurrence; translate?: [x,y,z]; axis?: [x,y,z], angle?: radians or \"30 deg\", origin?: [x,y,z]; capture?: bool (default true; false keeps it pending until Capture Position)"), + CommandSpec::new("occurrence.ground", "Ground", ground).params("occurrence; grounded?: bool (default toggles)"), + CommandSpec::new("SnapshotCmd", "Capture Position", capture).at("SOLID", "POSITION").icon("capture").params("keeps pending occurrence moves"), + CommandSpec::new("AsBuiltPositionsCmd", "Revert Position", revert).at("SOLID", "POSITION").icon("revert").noundo().params("drops pending occurrence moves"), ]; -fn component_key(v: Option<&Value>) -> Option { +fn key(v: Option<&Value>) -> Option { match v { Some(Value::Number(n)) => Some(n.to_string()), Some(Value::String(s)) => Some(s.clone()), @@ -28,18 +43,30 @@ fn component_key(v: Option<&Value>) -> Option { } } +fn component(s: &Session, p: &Value, k: &str, cmd: &str) -> Result { + let kk = key(p.get(k)).ok_or_else(|| bad(cmd, format!("`{k}` must be a component id or name")))?; + s.doc.find_component(&kk).ok_or_else(|| bad(cmd, format!("no component `{kk}`"))) +} + +fn occurrence(s: &Session, p: &Value, cmd: &str) -> Result { + let kk = key(p.get("occurrence")).ok_or_else(|| bad(cmd, "`occurrence` must be an id or name"))?; + s.doc.occurrences.iter().find(|o| o.id.to_string() == kk || o.name == kk).map(|o| o.id).ok_or_else(|| bad(cmd, format!("no occurrence `{kk}`"))) +} + +fn translation(t: Vec3) -> Mat { + solvecraft_doc::rigid(t, Vec3::ZERO, Vec3::Z, 0.0) +} + fn new_component(s: &mut Session, p: &Value) -> Result { let cmd = "FusionCreateNewComponentCommand"; - let parent = match component_key(p.get("parent")) { - Some(k) => s.doc.find_component(&k).ok_or_else(|| bad(cmd, format!("no component `{k}`")))?, - None => s.active_component, - }; + let parent = if p.get("parent").is_some() { component(s, p, "parent", cmd)? } else { s.active_component }; let id = s.doc_mut().add_component(str_(p, "name"), parent)?; if p.get("activate").and_then(Value::as_bool).unwrap_or(true) { s.active_component = id; } let name = s.doc.components.iter().find(|c| c.id == id).map(|c| c.name.clone()).unwrap_or_default(); - Ok(json!({"component": id, "name": name, "parent": parent})) + let occ = s.doc.occurrence_of(id).map(|o| o.id); + Ok(json!({"component": id, "name": name, "parent": parent, "occurrence": occ})) } fn from_bodies(s: &mut Session, p: &Value) -> Result { @@ -65,9 +92,7 @@ fn from_bodies(s: &mut Session, p: &Value) -> Result { } fn activate(s: &mut Session, p: &Value) -> Result { - let cmd = "component.activate"; - let k = component_key(p.get("component")).ok_or_else(|| bad(cmd, "`component` must be an id or name"))?; - let id = s.doc.find_component(&k).ok_or_else(|| bad(cmd, format!("no component `{k}`")))?; + let id = component(s, p, "component", "component.activate")?; s.active_component = id; s.revision += 1; Ok(json!({ "active": id })) @@ -78,12 +103,266 @@ fn list(s: &mut Session, _p: &Value) -> Result { let doc = &s.doc; let node = |id: u64, name: &str, parent: Option| { let bodies: Vec<&str> = st.bodies.iter().filter(|b| doc.body_component(&b.name, b.feature) == id).map(|b| b.name.as_str()).collect(); - let features: Vec<&str> = doc.features.iter().filter(|f| f.component == id).map(|f| f.name.as_str()).collect(); - json!({"id": id, "name": name, "parent": parent, "bodies": bodies, "features": features}) + let features: Vec<&str> = doc + .features + .iter() + .filter(|f| f.component == id && !matches!(f.kind, FeatureKind::Sketch { .. } | FeatureKind::ConstructionPlane { .. })) + .map(|f| f.name.as_str()) + .collect(); + let sketches: Vec<&str> = + doc.features.iter().filter(|f| f.component == id && matches!(f.kind, FeatureKind::Sketch { .. })).map(|f| f.name.as_str()).collect(); + let construction: Vec<&str> = doc + .features + .iter() + .filter(|f| f.component == id && matches!(f.kind, FeatureKind::ConstructionPlane { .. })) + .map(|f| f.name.as_str()) + .collect(); + let occurrences: Vec<&Occurrence> = doc.occurrences.iter().filter(|o| o.component == id).collect(); + let children: Vec<&Occurrence> = doc.occurrences.iter().filter(|o| o.parent == id).collect(); + json!({"id": id, "name": name, "parent": parent, "bodies": bodies, "features": features, "sketches": sketches, + "construction": construction, "occurrences": occurrences, "children": children}) }; let mut out = vec![node(0, &doc.name, None)]; for c in &doc.components { out.push(node(c.id, &c.name, Some(c.parent))); } - Ok(json!({"active": s.active_component, "components": out})) + Ok(json!({"active": s.active_component, "components": out, "pending_moves": s.pending_moves.keys().collect::>()})) +} + +fn rename(s: &mut Session, p: &Value) -> Result { + let cmd = "component.rename"; + let id = component(s, p, "component", cmd)?; + let name = + str_(p, "name").map(str::trim).filter(|n| !n.is_empty() && n.len() <= 128).ok_or_else(|| bad(cmd, "`name` must be 1…128 characters"))?; + if id == 0 { + s.doc_mut().name = name.to_string(); + } else if let Some(c) = s.doc_mut().components.iter_mut().find(|c| c.id == id) { + c.name = name.to_string(); + } + Ok(json!({"component": id, "name": name})) +} + +fn delete(s: &mut Session, p: &Value) -> Result { + let cmd = "component.delete"; + let id = component(s, p, "component", cmd)?; + if id == 0 { + return Err(bad(cmd, "the design itself can't be deleted")); + } + let gone: Vec = s.doc.components.iter().filter(|c| s.doc.component_within(c.id, id)).map(|c| c.id).collect(); + let feats: Vec = s.doc.features.iter().filter(|f| gone.contains(&f.component)).map(|f| f.id).collect(); + let d = s.doc_mut(); + for f in &feats { + let _ = d.delete_feature(*f); + } + d.components.retain(|c| !gone.contains(&c.id)); + d.occurrences.retain(|o| !gone.contains(&o.component) && !gone.contains(&o.parent)); + d.body_components.retain(|_, c| !gone.contains(c)); + if gone.contains(&s.active_component) { + s.active_component = 0; + } + Ok(json!({"deleted": gone, "features": feats})) +} + +fn move_bodies(s: &mut Session, p: &Value) -> Result { + let cmd = "component.move_bodies"; + let to = component(s, p, "component", cmd)?; + let bodies = string_list(p, "bodies"); + let st = s.model.state(); + if bodies.is_empty() || bodies.iter().any(|b| st.body(b).is_none()) { + return Err(bad(cmd, "`bodies` must list existing bodies")); + } + for b in &bodies { + let feature = st.body(b).map(|x| x.feature).unwrap_or(0); + let own = s.doc.feature(feature).map(|f| f.component).unwrap_or(0); + if own == to { + s.doc_mut().body_components.remove(b); + } else { + s.doc_mut().body_components.insert(b.clone(), to); + } + } + Ok(json!({"bodies": bodies, "component": to})) +} + +fn move_sketches(s: &mut Session, p: &Value) -> Result { + let cmd = "component.move_sketches"; + let to = component(s, p, "component", cmd)?; + let keys = string_list(p, "sketches"); + let mut ids = Vec::new(); + for k in &keys { + match s.doc.find_feature(k) { + Some(f) if matches!(f.kind, FeatureKind::Sketch { .. }) => ids.push(f.id), + _ => return Err(bad(cmd, format!("no sketch `{k}`"))), + } + } + if ids.is_empty() { + return Err(bad(cmd, "`sketches` must list sketches")); + } + for id in &ids { + if let Some(f) = s.doc_mut().feature_mut(*id) { + f.component = to; + } + } + Ok(json!({"sketches": ids, "component": to})) +} + +fn copy_occurrence(s: &mut Session, p: &Value) -> Result { + let cmd = "occurrence.copy"; + let comp = if p.get("occurrence").is_some() { + let o = occurrence(s, p, cmd)?; + s.doc.occurrences.iter().find(|x| x.id == o).map(|x| x.component).unwrap_or(0) + } else { + component(s, p, "component", cmd)? + }; + let src = s.doc.occurrence_of(comp).cloned().ok_or_else(|| bad(cmd, "that component has no occurrence"))?; + let parent = if p.get("parent").is_some() { component(s, p, "parent", cmd)? } else { src.parent }; + let t = p.get("translate").and_then(vec3).unwrap_or(Vec3::ZERO); + let m = solvecraft_doc::mat_mul(&translation(t), &src.transform); + let id = s.doc_mut().add_occurrence(comp, parent, m)?; + Ok(json!({"occurrence": id, "component": comp})) +} + +fn paste_new(s: &mut Session, p: &Value) -> Result { + let cmd = "component.paste_new"; + let comp = component(s, p, "component", cmd)?; + if comp == 0 { + return Err(bad(cmd, "pick a component, not the design")); + } + let src = s.doc.components.iter().find(|c| c.id == comp).cloned().ok_or_else(|| bad(cmd, "component"))?; + let occ = s.doc.occurrence_of(comp).cloned(); + let new_id = s.doc_mut().add_component(Some(&format!("{} (copy)", src.name)), src.parent)?; + // Copy the component's features with fresh ids and names; references between them follow. + let feats: Vec = s.doc.features.iter().filter(|f| f.component == comp).cloned().collect(); + let mut idmap: std::collections::HashMap = Default::default(); + let mut namemap: std::collections::HashMap = Default::default(); + let mut new_ids = Vec::new(); + for f in &feats { + let nid = s.doc_mut().add_feature(f.kind.clone(), Some(&format!("{} (copy)", f.name)))?; + idmap.insert(f.id, nid); + let nname = s.doc.feature(nid).map(|x| x.name.clone()).unwrap_or_default(); + namemap.insert(f.name.clone(), nname); + new_ids.push(nid); + } + for nid in &new_ids { + let Some(f) = s.doc.feature(*nid).cloned() else { continue }; + let mut v = serde_json::to_value(&f.kind).map_err(|e| bad(cmd, e.to_string()))?; + remap(&mut v, &idmap, &namemap, 0); + let kind: FeatureKind = serde_json::from_value(v).map_err(|e| bad(cmd, e.to_string()))?; + if let Some(slot) = s.doc_mut().feature_mut(*nid) { + slot.kind = kind; + slot.component = new_id; + } + } + let t = p.get("translate").and_then(vec3).unwrap_or(Vec3::ZERO); + let base = occ.map(|o| o.transform).unwrap_or(solvecraft_doc::IDENTITY); + if let Some(o) = s.doc_mut().occurrences.iter_mut().find(|o| o.component == new_id) { + o.transform = solvecraft_doc::mat_mul(&translation(t), &base); + } + Ok(json!({"component": new_id, "features": new_ids})) +} + +/// Point copied features at their copied sketches and features. +fn remap(v: &mut Value, ids: &std::collections::HashMap, names: &std::collections::HashMap, depth: usize) { + if depth > 32 { + return; + } + match v { + Value::Object(o) => { + for (k, x) in o.iter_mut() { + match (k.as_str(), &*x) { + ("sketch" | "path_sketch", Value::Number(n)) => { + if let Some(m) = n.as_u64().and_then(|i| ids.get(&i)) { + *x = json!(m); + } + } + ("features", Value::Array(_)) => { + if let Value::Array(a) = x { + for e in a.iter_mut() { + if let Some(m) = e.as_str().and_then(|n| names.get(n)) { + *e = json!(m); + } + } + } + } + _ => remap(x, ids, names, depth + 1), + } + } + } + Value::Array(a) => a.iter_mut().for_each(|x| remap(x, ids, names, depth + 1)), + _ => {} + } +} + +fn move_occurrence(s: &mut Session, p: &Value) -> Result { + let cmd = "occurrence.move"; + let id = occurrence(s, p, cmd)?; + let o = s.doc.occurrences.iter().find(|o| o.id == id).cloned().ok_or_else(|| bad(cmd, "occurrence"))?; + if o.grounded { + return Err(bad(cmd, format!("{} is grounded", o.name))); + } + let t = p.get("translate").and_then(vec3).unwrap_or(Vec3::ZERO); + let axis = p.get("axis").and_then(vec3).unwrap_or(Vec3::Z); + let angle = match p.get("angle") { + Some(Value::String(e)) => s.doc.eval(e, solvecraft_doc::expr::Kind::Angle).map_err(|e| bad(cmd, e.to_string()))?, + _ => num(p, "angle").unwrap_or(0.0), + }; + if !(t.is_finite() && angle.is_finite()) { + return Err(bad(cmd, "non-finite move")); + } + let origin = p.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO); + let delta = solvecraft_doc::rigid(t, origin, axis, angle); + let cur = s.pending_moves.get(&id).copied().unwrap_or(o.transform); + let next = solvecraft_doc::mat_mul(&delta, &cur); + if bool_(p, "capture").unwrap_or(true) { + s.pending_moves.remove(&id); + if let Some(x) = s.doc_mut().occurrences.iter_mut().find(|x| x.id == id) { + x.transform = next; + } + } else { + s.pending_moves.insert(id, next); + s.revision += 1; + } + Ok(json!({"occurrence": id, "transform": next, "pending": !bool_(p, "capture").unwrap_or(true)})) +} + +fn ground(s: &mut Session, p: &Value) -> Result { + let cmd = "occurrence.ground"; + let id = occurrence(s, p, cmd)?; + let want = bool_(p, "grounded"); + let o = s.doc_mut().occurrences.iter_mut().find(|o| o.id == id).ok_or_else(|| bad(cmd, "occurrence"))?; + o.grounded = want.unwrap_or(!o.grounded); + let g = o.grounded; + Ok(json!({"occurrence": id, "grounded": g})) +} + +fn capture(s: &mut Session, _p: &Value) -> Result { + let moves = std::mem::take(&mut s.pending_moves); + let n = moves.len(); + for (id, m) in moves { + if let Some(o) = s.doc_mut().occurrences.iter_mut().find(|o| o.id == id) { + o.transform = m; + } + } + s.revision += 1; + Ok(json!({ "captured": n })) +} + +fn revert(s: &mut Session, _p: &Value) -> Result { + let n = s.pending_moves.len(); + s.pending_moves.clear(); + s.revision += 1; + Ok(json!({ "reverted": n })) +} + +/// The active component's frame from world coordinates (for new features' geometry). +pub fn to_active_frame(s: &Session, kind: &mut FeatureKind) { + if s.active_component == 0 { + return; + } + let m = s.doc.component_transform(s.active_component); + if solvecraft_doc::is_identity(&m) { + return; + } + if let Some(inv) = solvecraft_doc::mat_inverse(&m) { + kind.transform_geometry(&inv); + } } diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 3c20d353..7b63862b 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -236,6 +236,9 @@ fn check_expr(s: &Session, e: &str, kind: Kind, cmd: &str, what: &str) -> Result /// Add a feature, evaluate, and report its result (an evaluation error fails the command). fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Result { let name = str_(p, "name"); + // Commands speak world coordinates; the feature is authored in the active component. + let mut kind = kind; + super::component::to_active_frame(s, &mut kind); let id = s.doc_mut().add_feature(kind, name)?; let comp = s.active_component; if comp != 0 diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 99f638cb..545b1c9d 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -160,7 +160,7 @@ fn save_as(s: &mut Session, p: &Value) -> Result { fn export_to(s: &Session, path: &str, format: Format, bodies: &[String]) -> Result { let name = std::path::Path::new(path).file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| s.doc.name.clone()); - let bytes = solvecraft_io::export(&s.model.state(), bodies, format, &name)?; + let bytes = solvecraft_io::export(&s.world_state(), bodies, format, &name)?; std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; Ok(json!({"path": path, "bytes": bytes.len(), "format": format!("{format:?}")})) } diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index 3a163411..d972caed 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -47,7 +47,7 @@ pub fn measure_json(b: &solvecraft_doc::ModelBody) -> Value { fn measure(s: &mut Session, p: &Value) -> Result { let want = string_list(p, "bodies"); - let st = s.model.state(); + let st = s.world_state(); let mut bodies: Vec = st.bodies.iter().filter(|b| want.is_empty() || want.contains(&b.name)).map(measure_json).collect(); // Mass from each body's material. for b in &mut bodies { @@ -197,7 +197,7 @@ fn body_of<'a>(st: &'a solvecraft_doc::ModelState, p: &Value, cmd: &str) -> Resu } fn model_edges(s: &mut Session, p: &Value) -> Result { - let st = s.model.state(); + let st = s.world_state(); let b = body_of(&st, p, "model.edges")?; let tol = (b.body.size() * 2e-3).max(1e-3); let edges: Vec = b @@ -210,7 +210,7 @@ fn model_edges(s: &mut Session, p: &Value) -> Result { } fn model_faces(s: &mut Session, p: &Value) -> Result { - let st = s.model.state(); + let st = s.world_state(); let b = body_of(&st, p, "model.faces")?; let tol = (b.body.size() * 2e-3).max(1e-3); let faces: Vec = b @@ -227,7 +227,7 @@ fn commands(s: &mut Session, _p: &Value) -> Result { } fn model_threads(s: &mut Session, _p: &Value) -> Result { - let st = s.model.state(); + let st = s.world_state(); Ok(json!({ "threads": st.threads })) } diff --git a/crates/engine/src/cmd/sketch.rs b/crates/engine/src/cmd/sketch.rs index 657ec5bc..4b41940f 100644 --- a/crates/engine/src/cmd/sketch.rs +++ b/crates/engine/src/cmd/sketch.rs @@ -354,7 +354,9 @@ fn create_sketch(s: &mut Session, p: &Value) -> Result { let (vals, _) = s.doc.param_values(); s.doc.resolve_plane(&vals, &plane, 0)?; let name = str_(p, "name"); - let id = s.doc_mut().add_feature(FeatureKind::Sketch { plane, sketch: Sketch::new() }, name)?; + let mut kind = FeatureKind::Sketch { plane, sketch: Sketch::new() }; + super::component::to_active_frame(s, &mut kind); + let id = s.doc_mut().add_feature(kind, name)?; let comp = s.active_component; if let Some(f) = s.doc_mut().feature_mut(id) { f.component = comp; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 456d4da8..9d847056 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -151,6 +151,9 @@ pub struct Session { pub auto_project: bool, /// The component new sketches and features go into (0 = the root). pub active_component: u64, + /// Occurrence moves not captured yet (Capture Position keeps them, a recompute drops them). + pub pending_moves: std::collections::BTreeMap, + world_cache: std::sync::Mutex)>>, } const MAX_UNDO: usize = 200; @@ -180,6 +183,8 @@ impl Session { revision: 1, auto_project: true, active_component: 0, + pending_moves: Default::default(), + world_cache: Default::default(), } } @@ -204,6 +209,37 @@ impl Session { } /// Re-evaluate the model after a document change. + /// The model placed in the world through component occurrences (and uncaptured moves): what + /// to display, measure, pick and export. The same as `model.state()` without components. + pub fn world_state(&self) -> Arc { + let st = self.model.state(); + if self.doc.occurrences.is_empty() { + return st; + } + if let Ok(c) = self.world_cache.lock() + && let Some((rev, w)) = c.as_ref() + && *rev == self.revision + { + return w.clone(); + } + let w = if self.pending_moves.is_empty() { + solvecraft_doc::world_state(&self.doc, &st) + } else { + let mut d = (*self.doc).clone(); + for o in &mut d.occurrences { + if let Some(m) = self.pending_moves.get(&o.id) { + o.transform = *m; + } + } + solvecraft_doc::world_state(&d, &st) + }; + let w = Arc::new(w); + if let Ok(mut c) = self.world_cache.lock() { + *c = Some((self.revision, w.clone())); + } + w + } + pub fn refresh(&mut self) { self.model.evaluate(&self.doc); self.revision += 1; @@ -272,6 +308,8 @@ impl Session { revision: self.revision, auto_project: self.auto_project, active_component: self.active_component, + pending_moves: self.pending_moves.clone(), + world_cache: Default::default(), } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index c8fa4564..7f2edd82 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -733,3 +733,69 @@ fn pipe_scale_offset_bounding_and_materials() { assert_eq!(m["bodies"][0]["material"], "Aluminum"); assert!(s.execute("PhysicalMaterialCommand", &json!({"bodies": ["Cube"], "material": "unobtainium"})).is_err()); } + +#[test] +fn components_occurrences_and_world_placement() { + let mut s = Session::default(); + run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "body_name": "Base"})); + // A component, active: its box is authored in its frame. + let c = run(&mut s, "FusionCreateNewComponentCommand", json!({"name": "Arm"})); + let (cid, occ) = (c["component"].as_u64().unwrap(), c["occurrence"].as_u64().unwrap()); + run(&mut s, "PrimitiveBox", json!({"corner": [20, 0, 0], "length": 10, "width": 4, "height": 4, "body_name": "ArmBody"})); + run(&mut s, "component.activate", json!({"component": 0})); + let bbox = |s: &mut Session, b: &str| run(s, "MeasureCommand", json!({ "bodies": [b] }))["bodies"][0]["bbox"].clone(); + assert_eq!(bbox(&mut s, "ArmBody")["min"][0], 20.0); + // Move the occurrence: the world placement follows, the authoring model doesn't change. + run(&mut s, "occurrence.move", json!({"occurrence": occ, "translate": [0, 50, 0]})); + assert!((bbox(&mut s, "ArmBody")["min"][1].as_f64().unwrap() - 50.0).abs() < 1e-9); + assert_eq!(s.model.state().body("ArmBody").unwrap().mesh().bounds().min.y, 0.0); + // A fillet picked in the world, while the component is active, lands on its edge. + run(&mut s, "component.activate", json!({"component": "Arm"})); + run(&mut s, "FusionFilletEdgesCommand", json!({"edges": [[25, 54, 4]], "radius": 1})); + let v = run(&mut s, "MeasureCommand", json!({"bodies": ["ArmBody"]}))["total"]["volume_mm3"].as_f64().unwrap(); + assert!(rel(v, 160.0 - (1.0 - PI / 4.0) * 10.0) < 1e-4, "{v}"); + run(&mut s, "component.activate", json!({"component": 0})); + // Instances share the contents; a copy is independent. + run(&mut s, "occurrence.copy", json!({"component": cid, "translate": [0, 20, 0]})); + let w = s.world_state(); + assert_eq!(w.bodies.len(), 3, "{:?}", w.bodies.iter().map(|b| &b.name).collect::>()); + let pn = run(&mut s, "component.paste_new", json!({"component": cid, "translate": [0, -30, 0]})); + assert_eq!(pn["features"].as_array().map(|a| a.len()), Some(2)); + assert_eq!(s.world_state().bodies.len(), 4); + // Ground stops moves; pending moves show until captured or reverted. + run(&mut s, "occurrence.ground", json!({"occurrence": occ})); + assert!(s.execute("occurrence.move", &json!({"occurrence": occ, "translate": [1, 0, 0]})).is_err()); + run(&mut s, "occurrence.ground", json!({"occurrence": occ, "grounded": false})); + run(&mut s, "occurrence.move", json!({"occurrence": occ, "translate": [0, 0, 5], "capture": false})); + assert!((bbox(&mut s, "ArmBody")["min"][2].as_f64().unwrap() - 5.0).abs() < 1e-9); + run(&mut s, "AsBuiltPositionsCmd", json!({})); + assert!(bbox(&mut s, "ArmBody")["min"][2].as_f64().unwrap().abs() < 1e-9); + run(&mut s, "occurrence.move", json!({"occurrence": occ, "translate": [0, 0, 5], "capture": false})); + run(&mut s, "SnapshotCmd", json!({})); + assert!(s.pending_moves.is_empty()); + assert!((bbox(&mut s, "ArmBody")["min"][2].as_f64().unwrap() - 5.0).abs() < 1e-9); + // Bodies and sketches move between components. + run(&mut s, "component.move_bodies", json!({"bodies": ["Base"], "component": "Arm"})); + let l = run(&mut s, "component.list", json!({})); + let arm = l["components"].as_array().unwrap().iter().find(|x| x["id"] == cid).unwrap().clone(); + assert!(arm["bodies"].as_array().unwrap().iter().any(|b| b == "Base"), "{arm}"); + run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "S1"})); + run(&mut s, "SketchStop", json!({})); + run(&mut s, "component.move_sketches", json!({"sketches": ["S1"], "component": cid})); + assert_eq!(s.doc.find_feature("S1").unwrap().component, cid); + // Undo/redo and the timeline work with features in components. + run(&mut s, "UndoCommand", json!({})); + assert_eq!(s.doc.find_feature("S1").unwrap().component, 0); + run(&mut s, "RedoCommand", json!({})); + run(&mut s, "timeline.rollTo", json!({"position": 1})); + // Only Base is left; it now lives in Arm, which is placed twice (original and instance). + assert_eq!(s.world_state().bodies.len(), 2); + run(&mut s, "timeline.rollTo", json!({})); + // Saved and loaded; older designs get occurrences. + let back = solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap(); + assert_eq!(back.occurrences.len(), s.doc.occurrences.len()); + let mut old: serde_json::Value = serde_json::from_str(&s.doc.to_json()).unwrap(); + old.as_object_mut().unwrap().remove("occurrences"); + let migrated = solvecraft_doc::Document::from_json(&old.to_string()).unwrap(); + assert_eq!(migrated.occurrences.len(), migrated.components.len()); +} diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index 4efa0db5..a9f89a2c 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -37,7 +37,7 @@ pub mod colors { /// Bounds of everything visible. pub fn bounds(s: &Session) -> Aabb3 { - let st = s.model.state(); + let st = s.world_state(); let mut b = Aabb3::EMPTY; for body in &st.bodies { b = b.union(&body.mesh().bounds()); @@ -92,7 +92,7 @@ pub fn sketch_lines(sk: &Sketch, plane: &Plane, active: bool, determined: &[bool /// The scene for a headless render. pub fn scene(s: &Session, cam: &Camera) -> Scene { - let st = s.model.state(); + let st = s.world_state(); let mut sc = Scene { background: Some((colors::BG_TOP, colors::BG_BOTTOM)), ..Default::default() }; let b = bounds(s); sc.radius = (b.diagonal() * 0.5).max(cam.half_height()).max(10.0) + b.center().dist(cam.target);