From 4cfb6dd3de03dc0d7a04d1da2857cf31466195ab Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:14:56 -0400 Subject: [PATCH] geometry: the replayed feature graph per body (#402); a partition may list a cut body for its cells Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- Cargo.lock | 1 + crates/engine/src/cmd/datum.rs | 12 +- crates/geometry/Cargo.toml | 1 + crates/geometry/src/design.rs | 23 +- crates/geometry/src/features.rs | 546 +++++++++++++++++++++++++++++++ crates/geometry/src/lib.rs | 2 + crates/geometry/src/partition.rs | 74 ++++- 7 files changed, 636 insertions(+), 23 deletions(-) create mode 100644 crates/geometry/src/features.rs diff --git a/Cargo.lock b/Cargo.lock index 8908ecb..c0a0482 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1356,6 +1356,7 @@ version = "0.6.0" dependencies = [ "fieldcraft-model", "quick-xml 0.42.0", + "serde", "serde_json", "solidcraft-doc", "solidcraft-engine", diff --git a/crates/engine/src/cmd/datum.rs b/crates/engine/src/cmd/datum.rs index eb09214..b6b3bef 100644 --- a/crates/engine/src/cmd/datum.rs +++ b/crates/engine/src/cmd/datum.rs @@ -94,7 +94,7 @@ pub const DATUM_DELETE: CommandSpec = CommandSpec { pub const PARTITION: CommandSpec = CommandSpec { id: "geometry.partition", label: "Partition", - params: r#"{"name": "Half", "datum": "Mid", "bodies"?: ["Body1"] (default: every body the plane divides)} — cuts bodies of the open design into cells by a datum plane, in memory (the design file is not changed); the model keeps the partition and applies it again whenever the design is opened, after the partitions before it. A cut body `B` becomes the cells `B/a` (on the plane's negative side) and `B/b` (several pieces on a side: `B/a1`, `B/a2`, …). The two faces of the cut are paired by name: `partition::a` on the cells on the negative side, `partition::b` on the positive side (`…#0`, `…#1` where a cell has several); every other face keeps the persistent name of the face it is a piece of (`#k` for several pieces on one cell), so sets by face name still select it. Reports the cells made and the cut faces named, and `warnings` when the plane lies on a face of a body it cuts or a cell's boundary does not tessellate into one a mesher takes (a cut through existing edges or vertices, such as a hole's axis), naming the plane, the cell and where; a body it cuts loses its mesh (`replaced`, as a mesher reports it; remesh the cells); a plane that divides no body, a listed body it does not divide, or a body the kernel fails to cut (its message) is an error"#, + params: r#"{"name": "Half", "datum": "Mid", "bodies"?: ["Body1"] (default: every body the plane divides; a body earlier partitions cut stands for its cells)} — cuts bodies of the open design into cells by a datum plane, in memory (the design file is not changed); the model keeps the partition and applies it again whenever the design is opened, after the partitions before it. A cut body `B` becomes the cells `B/a` (on the plane's negative side) and `B/b` (several pieces on a side: `B/a1`, `B/a2`, …). The two faces of the cut are paired by name: `partition::a` on the cells on the negative side, `partition::b` on the positive side (`…#0`, `…#1` where a cell has several); every other face keeps the persistent name of the face it is a piece of (`#k` for several pieces on one cell), so sets by face name still select it. Reports the cells made and the cut faces named, and `warnings` when the plane lies on a face of a body it cuts or a cell's boundary does not tessellate into one a mesher takes (a cut through existing edges or vertices, such as a hole's axis), naming the plane, the cell and where; a body it cuts loses its mesh (`replaced`, as a mesher reports it; remesh the cells); a plane that divides no body, a listed body it does not divide, or a body the kernel fails to cut (its message) is an error"#, form: Some(super::forms::PARTITION), kind: CommandKind::Model, requires: &[Requirement::Design, Requirement::DatumPlane], @@ -478,7 +478,7 @@ pub(crate) fn partitioned(design: &Design, model: &Model) -> Result { /// Cut the session's design by its model's partitions again. The bodies that are gone (cells /// whose partition went, a body a new partition cut) take their meshes with them (#385): what /// went is in the returned [`super::replace::Cleared`], which the command reports. -fn recut(s: &mut Session) -> Result { +pub(super) fn recut(s: &mut Session) -> Result { let Some(d) = &s.design else { return Ok(super::replace::Cleared::default()) }; let cut = partitioned(d, &s.model)?; let gone: Vec = d.bodies.iter().filter(|b| cut.body(&b.name).is_none()).map(|b| b.name.clone()).collect(); @@ -508,7 +508,7 @@ fn partition(s: &mut Session, p: &Value) -> Result { let (faces, bodies): (usize, Vec) = (report.map_or(0, |r| r.faces), design.bodies.iter().map(|b| b.name.clone()).collect()); let mut warnings: Vec = report.map(|r| r.warnings.clone()).unwrap_or_default(); for cell in &cells { - if let Some(why) = design.body(cell).and_then(|shape| unmeshable(shape).err()) { + if let Some(why) = design.body(cell).and_then(|shape| unmeshable(shape, None).err()) { warnings.push(format!( "partition `{name}`: cell `{cell}` has a boundary no mesher takes ({why}): the plane passes within the kernel's tolerance of a face, edge or vertex it cut (along a face, through the axis of a hole); move the plane off it (by a fraction of the element size)" )); @@ -534,10 +534,10 @@ fn partition(s: &mut Session, p: &Value) -> Result { Ok(cleared.report_if_any(s, reply)) } -/// Whether a cell's boundary, tessellated as the meshers take it by default, bounds a volume mesh: +/// Whether a cell's boundary, tessellated as the meshers take it (by default, or at `tolerance`), bounds a volume mesh: /// the defects (open edges, edges on three triangles) when it does not. -fn unmeshable(shape: &fieldcraft_geometry::Shape) -> Result<()> { - let input = super::hex::shape_input(shape, None)?; +pub(super) fn unmeshable(shape: &fieldcraft_geometry::Shape, tolerance: Option) -> Result<()> { + let input = super::hex::shape_input(shape, tolerance)?; let options = fieldcraft_mesh::surface::SurfaceOptions { weld_distance: input.mesh.weld_distance, ..Default::default() }; fieldcraft_mesh::surface::BoundarySurface::new(&input.mesh.positions, &input.mesh.triangles, &input.mesh.tri_face, &options) .map(|_| ()) diff --git a/crates/geometry/Cargo.toml b/crates/geometry/Cargo.toml index 30ba374..48e36c4 100644 --- a/crates/geometry/Cargo.toml +++ b/crates/geometry/Cargo.toml @@ -18,6 +18,7 @@ thiserror = { workspace = true } zip = { workspace = true } quick-xml = { workspace = true } solidcraft-engine = { workspace = true, optional = true } +serde = { workspace = true } serde_json = { workspace = true } [features] diff --git a/crates/geometry/src/design.rs b/crates/geometry/src/design.rs index 493e4cc..4ea17e7 100644 --- a/crates/geometry/src/design.rs +++ b/crates/geometry/src/design.rs @@ -27,6 +27,8 @@ pub struct Design { pub cuts: Vec, /// The bodies as opened, before any partition: what [`Design::partitioned`] starts from. pub(crate) source: std::sync::Arc>, + /// The timeline the design was rebuilt from ([`Design::features`]). + pub(crate) history: Option>, } /// A feature of the design that failed to rebuild. @@ -77,6 +79,11 @@ impl Design { self.bodies.iter().find(|b| b.name == name) } + /// The body `name` of the design as opened (before any partition). + pub fn source_body(&self, name: &str) -> Option<&Shape> { + self.source.iter().find(|b| b.name == name) + } + /// The body of the design as opened that `name` is, or that partitions cut it from /// (`Base` for `Base/a/b1`). pub fn source_of(&self, name: &str) -> Option<&str> { @@ -101,6 +108,11 @@ pub(crate) fn rebuild(doc: &Document) -> Result { m.evaluate(doc); Ok(m) })?; + let history = crate::features::History { + doc: doc.clone(), + state: model.state(), + errors: model.results.iter().filter(|r| !r.skipped).filter_map(|r| Some((r.id, r.error.clone()?))).collect(), + }; // Placed in the world: a component's bodies through every occurrence of it (an instance is a // body of its own, named after its occurrence path), as SolidCraft displays and exports them. let state = guard("place components", || Ok(solidcraft_doc::world_state(doc, &model.state())))?; @@ -137,13 +149,20 @@ pub(crate) fn rebuild(doc: &Document) -> Result { e }) .collect(); - Ok(Design { name: doc.name.clone(), source: std::sync::Arc::new(bodies.clone()), bodies, errors, cuts: Vec::new() }) + Ok(Design { + name: doc.name.clone(), + source: std::sync::Arc::new(bodies.clone()), + bodies, + errors, + cuts: Vec::new(), + history: Some(std::sync::Arc::new(history)), + }) } /// A body name without the suffixes SolidCraft gives the pieces of a split body: ` (1)` for the /// new piece and `'` for a piece kept under its old name, any number of times /// (`Bracket (1) (1)' (1)` → `Bracket`). -fn split_base(name: &str) -> &str { +pub(crate) fn split_base(name: &str) -> &str { let mut s = name; loop { let t = s.trim_end_matches('\''); diff --git a/crates/geometry/src/features.rs b/crates/geometry/src/features.rs new file mode 100644 index 0000000..26ee084 --- /dev/null +++ b/crates/geometry/src/features.rs @@ -0,0 +1,546 @@ +//! The feature graph of an opened design: for each body, the features of the SolidCraft timeline +//! that built it, with their parameters evaluated and the persistent face and edge names each +//! one left on the body. +//! +//! A design opened from `.solidcraft` is rebuilt from its timeline, so how every body was made is +//! known, not guessed: an extrude of a sketch profile along a direction, a box, a shell of a +//! thickness, a hole of a diameter. The provenance-driven mesh plan (`mesh.plan`) reads it. +//! +//! Which features built a body comes from two places: the persistent face names (each names the +//! features that made the face, `F8:side:l1`, `F3:inner:F1:+y`, `F82:-z@F84.2` for a pattern +//! copy), and the features that name the body (targets, tools, the body a fillet or shell +//! works on). A feature whose faces were all removed later and that names no body (a cut that a +//! later cut swallowed) is left out. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde::Serialize; +use solidcraft_doc::expr::Kind; +use solidcraft_doc::{Document, FeatureKind, ModelState, Operation, SolvedSketch}; +use solidcraft_geom::{Seg2, Vec3}; +use solidcraft_kernel::guard; + +use crate::{Design, Result, Shape}; + +/// Most features listed per body (a design's timeline is capped far above any real part). +pub const MAX_FEATURES_PER_BODY: usize = 10_000; + +/// The timeline a design was rebuilt from, kept with the design for [`Design::features`]. +pub(crate) struct History { + pub(crate) doc: Document, + pub(crate) state: std::sync::Arc, + /// Error per feature id, from the rebuild. + pub(crate) errors: BTreeMap, +} + +impl std::fmt::Debug for History { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("History").field("features", &self.doc.features.len()).field("errors", &self.errors).finish() + } +} + +/// The features that built one body, in timeline order. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct BodyFeatures { + pub body: String, + pub features: Vec, +} + +/// One feature of the timeline as it built a body. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct FeatureNode { + /// The feature's id: `F` in persistent names. + pub id: u64, + pub name: String, + /// The definition's type (`extrude`, `box`, `cylinder`, `hole`, `shell`, `fillet`, …). + #[serde(rename = "type")] + pub kind: String, + /// `new_body`, `join`, `cut` or `intersect`, for features that have one. + #[serde(skip_serializing_if = "Option::is_none")] + pub operation: Option, + /// The numeric inputs evaluated: lengths in mm, angles in radians, counts as numbers, keyed + /// by their snake-case label (`distance`, `radius`, `thickness`, …). + pub values: BTreeMap, + /// Where a primitive sits (a box's corner, a cylinder's base, a sphere's centre), mm. + #[serde(skip_serializing_if = "Option::is_none")] + pub placement: Option<[f64; 3]>, + /// The direction the feature builds along: an extrude's (the sketch normal, flipped for a + /// negative extrude), a cylinder's axis, a hole's drilling direction. + #[serde(skip_serializing_if = "Option::is_none")] + pub direction: Option<[f64; 3]>, + /// An extrude in both directions (`symmetric` or a second distance). + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub two_sided: bool, + /// The sketch the feature takes its profiles from, with the profiles it uses. + #[serde(skip_serializing_if = "Option::is_none")] + pub sketch: Option, + /// Pattern and mirror copies: each copy's 4×4 transform (row-major), the identity excluded. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub transforms: Vec<[[f64; 4]; 4]>, + /// The bodies a join, cut or intersect changes (empty: all), or a combine's target. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub targets: Vec, + /// The tool bodies of a combine. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub tools: Vec, + /// The names the feature gives the bodies it makes (a combine's tool is found by them). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub creates: Vec, + /// The definition as stored in the design (expressions unevaluated). + pub definition: serde_json::Value, + /// The body's faces this feature made or shaped (their persistent names name it). + pub faces: Vec, + /// The body's edges on those faces. + pub edges: Vec, + /// Why the feature failed to rebuild, when it did. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// A sketch plane and the profiles a feature uses from it (sketch coordinates, mm). +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct SketchGeometry { + pub id: u64, + pub name: String, + pub origin: [f64; 3], + pub x_dir: [f64; 3], + pub y_dir: [f64; 3], + pub normal: [f64; 3], + pub profiles: Vec, +} + +/// A closed profile: its outer loop and holes. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct ProfileGeometry { + /// Index in the solved sketch's profile list. + pub index: usize, + pub area: f64, + pub outer: Vec, + pub holes: Vec>, + /// The sketch curve ids of the outer loop and of each hole. + pub outer_curves: Vec, + pub hole_curves: Vec>, +} + +/// A curve of a profile loop (sketch coordinates). A loop of arcs about one centre that closes is +/// one [`Curve::Circle`]. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Curve { + Line { + a: [f64; 2], + b: [f64; 2], + }, + Arc { + center: [f64; 2], + radius: f64, + start: f64, + sweep: f64, + }, + Circle { + center: [f64; 2], + radius: f64, + }, + /// A spline, conic or other curve the planner does not decompose. + Other { + kind: String, + }, +} + +fn xyz(v: Vec3) -> [f64; 3] { + [v.x, v.y, v.z] +} + +/// `v` scaled to unit length, components within 1e-12 of zero made zero (a drilling direction +/// found from the geometry carries round-off). +fn unit(v: [f64; 3]) -> Option<[f64; 3]> { + let n = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); + (n.is_finite() && n > 1e-12).then(|| v.map(|c| if (c / n).abs() < 1e-12 { 0.0 } else { c / n })) +} + +/// The ids of the features a persistent face name mentions (`F3:inner:F1:+y` → 3, 1). +pub fn feature_ids(name: &str) -> impl Iterator + '_ { + let bytes = name.as_bytes(); + (0..bytes.len()).filter_map(move |i| { + let starts = bytes.get(i) == Some(&b'F') && (i == 0 || !bytes.get(i - 1).is_some_and(|b| b.is_ascii_alphanumeric())); + if !starts { + return None; + } + let digits: &str = name.get(i + 1..)?.split(|c: char| !c.is_ascii_digit()).next()?; + if digits.is_empty() { None } else { digits.parse().ok() } + }) +} + +/// `Taper angle` → `taper_angle`, `Distance (0 to 1)` → `distance_0_to_1`. +fn snake(label: &str) -> String { + let mut out = String::with_capacity(label.len()); + for c in label.chars() { + if c.is_ascii_alphanumeric() { + out.push(c.to_ascii_lowercase()); + } else if !out.ends_with('_') && !out.is_empty() { + out.push('_'); + } + } + out.trim_end_matches('_').to_string() +} + +fn operation_name(op: Operation) -> &'static str { + match op { + Operation::NewBody => "new_body", + Operation::Join => "join", + Operation::Cut => "cut", + Operation::Intersect => "intersect", + } +} + +fn curves(segs: &[Seg2]) -> Vec { + // A loop of arcs about one centre and radius that turns a full circle is a circle. + let arcs: Vec<(solidcraft_geom::Vec2, f64, f64)> = + segs.iter().filter_map(|s| if let Seg2::Arc { center, radius, sweep, .. } = s { Some((*center, *radius, *sweep)) } else { None }).collect(); + if !arcs.is_empty() && arcs.len() == segs.len() { + let (c0, r0, _) = arcs[0]; + let same = arcs.iter().all(|(c, r, _)| (c.x - c0.x).abs() < 1e-9 && (c.y - c0.y).abs() < 1e-9 && (r - r0).abs() < 1e-9); + let turn: f64 = arcs.iter().map(|a| a.2).sum(); + if same && (turn.abs() - std::f64::consts::TAU).abs() < 1e-6 { + return vec![Curve::Circle { center: [c0.x, c0.y], radius: r0 }]; + } + } + segs.iter() + .map(|s| match s { + Seg2::Line { a, b } => Curve::Line { a: [a.x, a.y], b: [b.x, b.y] }, + Seg2::Arc { center, radius, start, sweep } => Curve::Arc { center: [center.x, center.y], radius: *radius, start: *start, sweep: *sweep }, + other => Curve::Other { kind: format!("{other:?}").split(['{', ' ', '(']).next().unwrap_or("curve").to_ascii_lowercase() }, + }) + .collect() +} + +fn sketch_geometry(ss: &SolvedSketch, sel: Option<&solidcraft_doc::ProfileSel>) -> SketchGeometry { + let indices: Vec = match sel { + Some(sel) => solidcraft_doc::profile_selection_indices(ss, sel).unwrap_or_else(|_| (0..ss.profiles.len()).collect()), + None => (0..ss.profiles.len()).collect(), + }; + let profiles = indices + .into_iter() + .filter_map(|i| { + let p = ss.profiles.get(i)?; + Some(ProfileGeometry { + index: i, + area: p.area, + outer: curves(&p.region.outer.segs), + holes: p.region.holes.iter().map(|h| curves(&h.segs)).collect(), + outer_curves: p.outer_curves.clone(), + hole_curves: p.hole_curves.clone(), + }) + }) + .collect(); + SketchGeometry { + id: ss.feature, + name: ss.name.clone(), + origin: xyz(ss.plane.origin), + x_dir: xyz(ss.plane.x), + y_dir: xyz(ss.plane.y), + normal: xyz(ss.plane.normal()), + profiles, + } +} + +impl History { + /// The feature `id` of the timeline, described against `body` (its faces and edges). + fn node(&self, f: &solidcraft_doc::Feature, faces: Vec, edges: Vec) -> FeatureNode { + let (vals, _) = self.doc.param_values(); + let eval = |e: &str, k: Kind| Document::eval_in(&vals, e, k).ok().filter(|v| v.is_finite()); + let mut values = BTreeMap::new(); + for (label, e, k) in f.kind.inputs() { + if let Some(v) = eval(&e, k) { + let mut key = snake(label); + let mut n = 2; + while values.contains_key(&key) { + key = format!("{}_{n}", snake(label)); + n += 1; + } + values.insert(key, v); + } + } + let point = + |p: &[String; 3]| -> Option<[f64; 3]> { Some([eval(&p[0], Kind::Length)?, eval(&p[1], Kind::Length)?, eval(&p[2], Kind::Length)?]) }; + let sketch_of = |id: u64| self.state.sketch(id); + let mut node = FeatureNode { + id: f.id, + name: f.name.clone(), + kind: String::new(), + operation: None, + values, + placement: None, + direction: None, + two_sided: false, + sketch: None, + transforms: Vec::new(), + targets: Vec::new(), + tools: Vec::new(), + creates: f.body_names.clone(), + definition: serde_json::to_value(&f.kind).unwrap_or_default(), + faces, + edges, + error: self.errors.get(&f.id).cloned(), + }; + node.kind = node.definition.get("type").and_then(serde_json::Value::as_str).unwrap_or("feature").to_string(); + match &f.kind { + FeatureKind::Extrude { sketch, profiles, extent, operation, targets, .. } => { + node.operation = Some(operation_name(*operation).into()); + node.targets = targets.clone(); + if let Some(ss) = sketch_of(*sketch) { + let n = xyz(ss.plane.normal()); + let negative = matches!(extent.direction, solidcraft_doc::Direction::Negative); + node.direction = unit(if negative { n.map(|c| -c) } else { n }); + node.two_sided = matches!(extent.direction, solidcraft_doc::Direction::Symmetric) || extent.distance2.is_some(); + node.sketch = Some(sketch_geometry(ss, Some(profiles))); + } + } + FeatureKind::Revolve { sketch, profiles, operation, targets, .. } | FeatureKind::Sweep { sketch, profiles, operation, targets, .. } => { + node.operation = Some(operation_name(*operation).into()); + node.targets = targets.clone(); + node.sketch = sketch_of(*sketch).map(|ss| sketch_geometry(ss, Some(profiles))); + } + FeatureKind::Loft { operation, targets, .. } => { + node.operation = Some(operation_name(*operation).into()); + node.targets = targets.clone(); + } + FeatureKind::Box { corner, operation, .. } => { + node.operation = Some(operation_name(*operation).into()); + node.placement = point(corner); + node.direction = Some([0.0, 0.0, 1.0]); + } + FeatureKind::Cylinder { base, axis, operation, .. } => { + node.operation = Some(operation_name(*operation).into()); + node.placement = point(base); + node.direction = unit(xyz(*axis)); + } + FeatureKind::Sphere { center, operation, .. } | FeatureKind::Torus { center, operation, .. } => { + node.operation = Some(operation_name(*operation).into()); + node.placement = point(center); + } + FeatureKind::Hole { position, direction, .. } => { + node.operation = Some("cut".into()); + node.placement = Some(xyz(*position)); + node.direction = unit(xyz(*direction)); + } + FeatureKind::Combine { target, tools, operation, .. } => { + node.operation = Some(operation_name(*operation).into()); + node.targets = vec![target.clone()]; + node.tools = tools.clone(); + } + FeatureKind::Shell { body, .. } | FeatureKind::Fillet { body, .. } | FeatureKind::Chamfer { body, .. } => { + node.targets = body.iter().cloned().collect(); + } + FeatureKind::Split { body, .. } => node.targets = vec![body.clone()], + FeatureKind::Pattern { pattern, bodies, .. } => { + node.targets = bodies.clone(); + if let Ok(m) = solidcraft_doc::pattern_matrices(&self.doc, &self.state, pattern) { + // SolidCraft's matrices are column-major (`m[3]` the translation); the graph's are row-major. + let rows = |m: [[f64; 4]; 4]| std::array::from_fn(|i| std::array::from_fn(|j| m[j][i])); + node.transforms = m.into_iter().filter(|m| *m != solidcraft_doc::IDENTITY).map(rows).collect(); + } + } + FeatureKind::Mirror { plane, bodies, .. } => { + node.targets = bodies.clone(); + if let Ok(p) = self.doc.resolve_plane_in(&vals, plane, 0, Some(&self.state)) + && let Some(n) = unit(xyz(p.normal())) + { + // x' = x − 2 ((x − o)·n) n + let o = xyz(p.origin); + let d = 2.0 * (o[0] * n[0] + o[1] * n[1] + o[2] * n[2]); + let mut m = solidcraft_doc::IDENTITY; + for (i, row) in m.iter_mut().take(3).enumerate() { + for (j, c) in row.iter_mut().take(3).enumerate() { + *c -= 2.0 * n[i] * n[j]; + } + row[3] = d * n[i]; + } + node.transforms = vec![m]; + } + } + _ => {} + } + node + } +} + +impl Design { + /// The features that built each body of the design as opened (`body`: only that one), in + /// timeline order. Partition cells are described by the body they were cut from. An error + /// for a design not opened from a timeline (shapes built in code) or a body it lacks. + pub fn features(&self, body: Option<&str>) -> Result> { + let history = self.history.as_deref().ok_or_else(|| crate::Error::Invalid("the design has no feature timeline".into()))?; + let shapes: Vec<&Shape> = match body { + Some(name) => { + let source = self.source_of(name).ok_or_else(|| crate::Error::Invalid(format!("no body `{name}`")))?; + self.source.iter().filter(|b| b.name == source).collect() + } + None => self.source.iter().collect(), + }; + shapes.into_iter().map(|shape| guard("feature graph", || Ok(body_features(history, shape))).map_err(crate::Error::from)?).collect() + } +} + +fn body_features(history: &History, shape: &Shape) -> Result { + // Faces by the features their names mention. + let mut faces: BTreeMap> = BTreeMap::new(); + for f in shape.faces() { + let ids: BTreeSet = feature_ids(&f.name).collect(); + for id in ids { + faces.entry(id).or_default().push(f.name.clone()); + } + } + let edges = shape.edges()?; + // The body as SolidCraft names it before split pieces were numbered (`Bracket.2` was + // `Bracket (1)`): features name it by its base. + let base = shape.name.rsplit_once('.').filter(|(_, n)| n.bytes().all(|b| b.is_ascii_digit())).map_or(shape.name.as_str(), |(b, _)| b); + let names_body = |f: &solidcraft_doc::Feature| { + let mentioned = |n: &String| n == &shape.name || n == base || crate::design::split_base(n) == base; + // The bodies the feature changes. A combine's tools are not among them: kept tools come + // out unchanged, and the others are gone. + let targets: Vec<&String> = match &f.kind { + FeatureKind::Extrude { targets, .. } + | FeatureKind::Revolve { targets, .. } + | FeatureKind::Sweep { targets, .. } + | FeatureKind::Loft { targets, .. } => targets.iter().collect(), + FeatureKind::Combine { target, .. } => vec![target], + FeatureKind::Shell { body, .. } | FeatureKind::Fillet { body, .. } | FeatureKind::Chamfer { body, .. } => body.iter().collect(), + FeatureKind::Split { body, .. } => vec![body], + // A body pattern or mirror: the original is the instance the copies repeat. + FeatureKind::Pattern { bodies, .. } | FeatureKind::Mirror { bodies, .. } => bodies.iter().collect(), + _ => Vec::new(), + }; + targets.into_iter().chain(&f.body_names).any(mentioned) + }; + let mut features = Vec::new(); + for f in history.doc.features.iter().filter(|f| !f.suppressed) { + if matches!( + f.kind, + FeatureKind::Sketch { .. } + | FeatureKind::ConstructionPlane { .. } + | FeatureKind::ConstructionAxis { .. } + | FeatureKind::ConstructionPoint { .. } + ) { + continue; + } + let own = faces.remove(&f.id).unwrap_or_default(); + if own.is_empty() && !names_body(f) { + continue; + } + let tagged: BTreeSet = own.iter().filter_map(|n| shape.face_index(n)).collect(); + let on: Vec = edges.iter().filter(|e| e.faces.iter().any(|i| tagged.contains(i))).map(|e| e.name.clone()).collect(); + features.push(history.node(f, own, on)); + if features.len() >= MAX_FEATURES_PER_BODY { + break; + } + } + Ok(BodyFeatures { body: shape.name.clone(), features }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn feature_ids_come_from_every_name_part() { + let ids = |n: &str| feature_ids(n).collect::>(); + assert_eq!(ids("F3:inner:F2:blend:F1:+y|F1:-x"), vec![3, 2, 1, 1]); + assert_eq!(ids("F82:-z@F84.2"), vec![82, 84]); + assert_eq!(ids("F44:side:l12"), vec![44]); + assert_eq!(ids("partition:Half:a"), Vec::::new()); + assert_eq!(ids("OFF7"), Vec::::new()); + assert_eq!(ids("F"), Vec::::new()); + } + + #[test] + fn the_plate_is_an_extrude_of_its_outline_and_a_cut_of_a_circle() { + use crate::sample::{Plate, plate_design}; + let plate = Plate::default(); + let design = crate::open_design(&plate_design(&plate).unwrap()).unwrap(); + let graph = design.features(None).unwrap(); + assert_eq!(graph.len(), 1); + let f = &graph[0].features; + assert_eq!( + f.iter().map(|n| (n.kind.as_str(), n.operation.as_deref())).collect::>(), + [("extrude", Some("new_body")), ("extrude", Some("cut"))] + ); + let (outline, hole) = (&f[0], &f[1]); + // Expressions come out evaluated: the thickness parameter, and `thickness + 1 mm`. + assert_eq!(outline.values.get("distance"), Some(&plate.thickness)); + assert_eq!(hole.values.get("distance"), Some(&(plate.thickness + 1.0))); + assert_eq!(outline.direction, Some([0.0, 0.0, 1.0])); + let sketch = outline.sketch.as_ref().unwrap(); + assert_eq!(sketch.normal, [0.0, 0.0, 1.0]); + assert_eq!(sketch.profiles.len(), 1); + assert!(sketch.profiles[0].outer.iter().all(|c| matches!(c, Curve::Line { .. })) && sketch.profiles[0].outer.len() == 4); + let circle = &hole.sketch.as_ref().unwrap().profiles[0].outer; + let c = plate.depth / 2.0; + assert_eq!(circle, &[Curve::Circle { center: [c, c], radius: plate.hole_diameter / 2.0 }]); + // Each feature's faces: the outline's four sides and caps, the hole's two halves. + assert_eq!(outline.faces.len(), 6, "{:?}", outline.faces); + assert_eq!(hole.faces.len(), 2, "{:?}", hole.faces); + assert!(!hole.edges.is_empty()); + assert!(outline.error.is_none() && hole.error.is_none()); + // Bodies the design lacks are an error, not an empty graph. + assert!(design.features(Some("Nope")).is_err()); + } + + #[test] + fn labels_become_snake_case_keys() { + assert_eq!(snake("Taper angle"), "taper_angle"); + assert_eq!(snake("Distance (0 to 1)"), "distance_0_to_1"); + assert_eq!(snake("Radius"), "radius"); + } + + /// Primitives, a shell, a hole, a combine, a body pattern and a mirror: each body lists the + /// features that built it, with their placements, directions, targets and transforms. + #[cfg(feature = "scripts")] + #[test] + fn a_timeline_of_primitives_and_copies_is_listed_per_body() { + let script = r#"[ + {"command": "solid.box", "params": {"length": 40, "width": 30, "height": 20, "body_name": "Case"}}, + {"command": "solid.shell", "params": {"faces": [[20, 15, 20]], "thickness": 2, "body": "Case"}}, + {"command": "solid.cylinder", "params": {"diameter": 6, "height": 5, "base": [10, 10, 2], "operation": "join", "targets": ["Case"]}}, + {"command": "solid.hole", "params": {"position": [10, 10, 7], "diameter": 2, "depth": 4}}, + {"command": "solid.box", "params": {"length": 4, "width": 4, "height": 20, "corner": [30, 20, 0], "body_name": "Slot"}}, + {"command": "solid.combine", "params": {"target": "Case", "tools": ["Slot"], "operation": "cut"}}, + {"command": "solid.box", "params": {"length": 1, "width": 1, "height": 3, "corner": [60, 0, 0], "body_name": "Pin"}}, + {"command": "solid.pattern.rectangular", "params": {"bodies": ["Pin"], "dir1": [1, 0, 0], "count1": 3, "spacing1": 2}}, + {"command": "solid.box", "params": {"length": 2, "width": 2, "height": 2, "corner": [0, 50, 0], "body_name": "Foot"}}, + {"command": "solid.mirror", "params": {"bodies": ["Foot"], "plane": {"origin": [10, 0, 0], "normal": [1, 0, 0]}}} + ]"#; + let design = crate::open_design(&crate::script_design(script).unwrap()).unwrap(); + let case = design.features(Some("Case")).unwrap(); + let kinds: Vec<&str> = case[0].features.iter().map(|f| f.kind.as_str()).collect(); + assert_eq!(kinds, ["box", "shell", "cylinder", "hole", "box", "combine"], "{case:#?}"); + let f = &case[0].features; + assert_eq!(f[0].placement, Some([0.0, 0.0, 0.0])); + assert_eq!((f[0].values["length"], f[0].values["width"], f[0].values["height"]), (40.0, 30.0, 20.0)); + assert_eq!(f[1].values["thickness"], 2.0); + assert_eq!((f[2].values["radius"], f[2].direction, f[2].operation.as_deref()), (3.0, Some([0.0, 0.0, 1.0]), Some("join"))); + assert_eq!((f[3].values["diameter"], f[3].direction, f[3].placement), (2.0, Some([0.0, 0.0, -1.0]), Some([10.0, 10.0, 7.0]))); + assert_eq!((f[5].targets.as_slice(), f[5].tools.as_slice()), (&["Case".to_string()][..], &["Slot".to_string()][..])); + // The cut tool's faces are the slot's walls in the case. + assert!(!f[4].faces.is_empty()); + // The pattern's copies: each a body of its own with the pattern's transforms. + let pins: Vec<&str> = design.bodies.iter().map(|b| b.name.as_str()).filter(|n| n.starts_with("Pin")).collect(); + assert_eq!(pins.len(), 3, "{pins:?}"); + for pin in pins { + let g = design.features(Some(pin)).unwrap(); + let kinds: Vec<&str> = g[0].features.iter().map(|f| f.kind.as_str()).collect(); + assert_eq!(kinds, ["box", "pattern"], "{pin}"); + let t = &g[0].features[1].transforms; + assert_eq!(t.len(), 2); + assert!(t.iter().any(|m| (m[0][3] - 2.0).abs() < 1e-9) && t.iter().any(|m| (m[0][3] - 4.0).abs() < 1e-9), "{t:?}"); + } + // The mirror: x' = 20 − x. + let foot = design.bodies.iter().find(|b| b.name.starts_with("Foot") && b.name != "Foot").map(|b| b.name.clone()).unwrap_or_default(); + let g = design.features(Some(&foot)).unwrap(); + let mirror = g[0].features.iter().find(|f| f.kind == "mirror").unwrap_or_else(|| panic!("{foot}: {g:#?}")); + assert_eq!(mirror.transforms, vec![[[-1.0, 0.0, 0.0, 20.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]]]); + // The cut tool is gone; the graph of every body comes in the design's order. + let all = design.features(None).unwrap(); + assert_eq!(all.iter().map(|b| b.body.as_str()).collect::>(), design.bodies.iter().map(|b| b.name.as_str()).collect::>()); + } +} diff --git a/crates/geometry/src/lib.rs b/crates/geometry/src/lib.rs index 9caf06d..3a41e65 100644 --- a/crates/geometry/src/lib.rs +++ b/crates/geometry/src/lib.rs @@ -19,6 +19,7 @@ mod design; mod edges; +pub mod features; pub mod partition; pub mod sample; #[cfg(feature = "scripts")] @@ -26,6 +27,7 @@ mod script; pub use design::{Design, FeatureError, MAX_DESIGN_BYTES, open_design}; pub use edges::{EDGE_TOLERANCE, Edge, Vertex}; +pub use features::{BodyFeatures, Curve, FeatureNode, ProfileGeometry, SketchGeometry}; pub use partition::{Cut, CutReport}; pub mod tagged_mesh; #[cfg(feature = "scripts")] diff --git a/crates/geometry/src/partition.rs b/crates/geometry/src/partition.rs index e5aa9e1..4071001 100644 --- a/crates/geometry/src/partition.rs +++ b/crates/geometry/src/partition.rs @@ -40,7 +40,8 @@ pub struct Cut { pub point: [f64; 3], pub normal: [f64; 3], /// The bodies to cut, by name, as they are when this partition comes (the cells of earlier - /// partitions included); empty: every body the plane divides. + /// partitions included); a body earlier partitions cut stands for its cells (`B` for `B/a`, + /// `B/b/a`, …); empty: every body the plane divides. pub bodies: Vec, } @@ -104,39 +105,45 @@ impl Design { let mut reports = Vec::with_capacity(cuts.len()); for (cut, (plane, n)) in cuts.iter().zip(&planes) { let feature = format!("partition {}", cut.name); + // A listed name is the body of that name, or the cells earlier partitions cut it into. + let names = |name: &str, b: &str| name == b || name.strip_prefix(b).is_some_and(|rest| rest.starts_with('/')); for b in &cut.bodies { - if !bodies.iter().any(|x| &x.name == b) { + if !bodies.iter().any(|x| names(&x.name, b)) { errors.push(FeatureError { feature: feature.clone(), message: format!("no body `{b}` to cut"), bodies: vec![b.clone()] }); } } let mut report = CutReport { name: cut.name.clone(), split: Vec::new(), faces: 0, warnings: Vec::new() }; let mut next = Vec::with_capacity(bodies.len() + 1); + // The listed names some body of which the plane divides. + let mut divided = vec![false; cut.bodies.len()]; for body in bodies { - let listed = cut.bodies.iter().any(|b| b == &body.name); - if !(cut.bodies.is_empty() || listed) { + let listed: Vec = (0..cut.bodies.len()).filter(|&i| cut.bodies.get(i).is_some_and(|b| names(&body.name, b))).collect(); + if !(cut.bodies.is_empty() || !listed.is_empty()) { next.push(body); continue; } match split(&body, plane, *n, cut)? { Split::Cells(cells, faces, warnings) => { + for &i in &listed { + if let Some(d) = divided.get_mut(i) { + *d = true; + } + } report.split.push(body.name.clone()); report.faces += faces; report.warnings.extend(warnings); next.extend(cells); } - Split::Misses => { - if listed { - errors.push(FeatureError { - feature: feature.clone(), - message: format!("the plane does not divide `{}`", body.name), - bodies: vec![body.name.clone()], - }); - } - next.push(body); - } + Split::Misses => next.push(body), // Listed or not: the plane may well divide the body, and a cut that silently // leaves it whole would be mistaken for one that does not reach it (#283). Split::Failed(e) => { + // Reported here; not again as a plane that divides nothing. + for &i in &listed { + if let Some(d) = divided.get_mut(i) { + *d = true; + } + } errors.push(FeatureError { feature: feature.clone(), message: format!("the kernel could not cut `{}` ({e})", body.name), @@ -149,10 +156,28 @@ impl Design { return Err(Error::Invalid(format!("the partitions make at most {MAX_CELLS} cells"))); } } + for (b, divided) in cut.bodies.iter().zip(divided) { + // A body that is not there is reported above; one that is, and that the plane + // misses (all of its cells), is an error: the cut would do nothing to it. + if !divided && next.iter().any(|x| names(&x.name, b)) { + errors.push(FeatureError { + feature: feature.clone(), + message: format!("the plane does not divide `{b}`"), + bodies: vec![b.clone()], + }); + } + } bodies = next; reports.push(report); } - Ok(Design { name: self.name.clone(), bodies, errors, cuts: reports, source: std::sync::Arc::clone(&self.source) }) + Ok(Design { + name: self.name.clone(), + bodies, + errors, + cuts: reports, + source: std::sync::Arc::clone(&self.source), + history: self.history.clone(), + }) } } @@ -544,6 +569,25 @@ mod tests { } } + /// A body cut before stands for its cells: listing it cuts the cells the plane divides, and + /// a plane that misses all of them is an error, as for a whole body. + #[test] + fn a_cut_body_listed_again_cuts_its_cells() { + let d = plate(); + let body = d.bodies[0].name.clone(); + let on = |name: &str, point, normal| Cut { bodies: vec![body.clone()], ..cut(name, point, normal) }; + let p = d.partitioned(&[on("X", [30.0, 0.0, 0.0], [1.0, 0.0, 0.0]), on("Z", [0.0, 0.0, 5.0], [0.0, 0.0, 1.0])]).unwrap(); + assert!(p.errors.is_empty(), "{:?}", p.errors); + assert_eq!(p.bodies.len(), 4); + // Only one cell (x < 30) reaches x = 20. + let p = d.partitioned(&[on("X", [30.0, 0.0, 0.0], [1.0, 0.0, 0.0]), on("Y", [20.0, 0.0, 0.0], [1.0, 0.0, 0.0])]).unwrap(); + assert!(p.errors.is_empty(), "{:?}", p.errors); + assert_eq!(p.bodies.len(), 3); + let p = d.partitioned(&[on("X", [30.0, 0.0, 0.0], [1.0, 0.0, 0.0]), on("Out", [100.0, 0.0, 0.0], [1.0, 0.0, 0.0])]).unwrap(); + assert_eq!(p.errors.iter().map(|e| e.feature.as_str()).collect::>(), ["partition Out"]); + assert_eq!(p.bodies.len(), 2); + } + #[test] fn a_plane_that_misses_or_a_missing_body_is_reported_not_fatal() { let d = plate();