From 86ea018d05e309e053e330a68a0a5d68757f6a5d Mon Sep 17 00:00:00 2001 From: JIJINBEI <87472238+jijinbei@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:14:25 +0900 Subject: [PATCH] One walk over a feature's references (2/3, #876) (#988) Part 2 of #876: one walk over the references a feature holds (the sketches it's built from, the features it names, the bodies it works on) replaces three hand-written matches, with a golden record covering every variant. --- crates/doc/src/clipboard.rs | 164 +---------- crates/doc/src/document.rs | 68 ----- crates/doc/src/feature_refs.rs | 271 ++++++++++++++++++ crates/doc/src/feature_walk_tests.rs | 243 ++++++---------- crates/doc/src/feature_walk_tests/fixtures.rs | 201 +++++++++++++ .../src/feature_walk_tests/golden_refs.txt | 175 +++++++++++ crates/doc/src/lib.rs | 1 + crates/doc/src/tests.rs | 35 --- crates/engine/src/cmd/clipboard.rs | 21 ++ crates/engine/src/cmd/surface.rs | 19 ++ 10 files changed, 778 insertions(+), 420 deletions(-) create mode 100644 crates/doc/src/feature_refs.rs create mode 100644 crates/doc/src/feature_walk_tests/fixtures.rs create mode 100644 crates/doc/src/feature_walk_tests/golden_refs.txt diff --git a/crates/doc/src/clipboard.rs b/crates/doc/src/clipboard.rs index 90ffd7d5..b6dc2e17 100644 --- a/crates/doc/src/clipboard.rs +++ b/crates/doc/src/clipboard.rs @@ -3,83 +3,7 @@ use std::collections::BTreeMap; -use crate::{FeatureKind, PatternKind, PlaneRef}; - -fn plane_names(p: &mut PlaneRef, names: &BTreeMap) { - let mut p = p; - for _ in 0..64 { - match p { - PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => p = base, - PlaneRef::Construction { name } => { - if let Some(n) = names.get(name) { - *name = n.clone(); - } - return; - } - _ => return, - } - } -} - -impl FeatureKind { - /// Point references to sketches (by id) and features (by name) at their copies. - pub fn remap_refs(&mut self, sketches: &BTreeMap, names: &BTreeMap) { - let sk = |id: &mut u64| { - if let Some(n) = sketches.get(id) { - *id = *n; - } - }; - let feats = |list: &mut Vec| { - for x in list.iter_mut() { - if let Some(n) = names.get(x) { - *x = n.clone(); - } - } - }; - match self { - FeatureKind::Sketch { plane, .. } | FeatureKind::ConstructionPlane { plane } | FeatureKind::Split { plane, .. } => { - plane_names(plane, names) - } - FeatureKind::SplitFace { plane, sketch, .. } => { - plane_names(plane, names); - if let Some(s) = sketch { - sk(s); - } - } - FeatureKind::Extrude { sketch, .. } - | FeatureKind::SurfaceExtrude { sketch, .. } - | FeatureKind::SurfaceRevolve { sketch, .. } - | FeatureKind::Revolve { sketch, .. } - | FeatureKind::Emboss { sketch, .. } - | FeatureKind::SheetBase { sketch, .. } - | FeatureKind::SheetContour { sketch, .. } - | FeatureKind::Rib { sketch, .. } => sk(sketch), - FeatureKind::Patch { sketch: Some(s), .. } => sk(s), - FeatureKind::Sweep { sketch, path_sketch, .. } | FeatureKind::SurfaceSweep { sketch, path_sketch, .. } => { - sk(sketch); - sk(path_sketch); - } - FeatureKind::Pipe { path_sketch, .. } => sk(path_sketch), - FeatureKind::SheetFold { sketch: Some(s), .. } => sk(s), - FeatureKind::Loft { sections, .. } => sections.iter_mut().for_each(|s| sk(&mut s.sketch)), - FeatureKind::SurfaceLoft { sections } => sections.iter_mut().for_each(|s| sk(&mut s.sketch)), - FeatureKind::Hole { points: Some(p), .. } => sk(&mut p.sketch), - FeatureKind::Pattern { features, pattern, .. } => { - feats(features); - if let PatternKind::Path { path_sketch, .. } = pattern { - sk(path_sketch); - } - } - FeatureKind::Mirror { features, plane, .. } => { - feats(features); - plane_names(plane, names); - } - FeatureKind::Draft { neutral, .. } => plane_names(neutral, names), - FeatureKind::ReplaceFace { target, .. } => plane_names(target, names), - _ => {} - } - } -} +use crate::{FeatureKind, PlaneRef}; /// Remap persistent topology tokens without rewriting arbitrary user expressions or labels. pub fn remap_topology(name: &str, ids: &BTreeMap, bodies: &BTreeMap) -> String { @@ -246,92 +170,6 @@ fn remap_link( } } -impl FeatureKind { - /// Point the body names a feature acts on (targets, tools, the body it rounds…) at the - /// copies in `bodies`; names not in the map stay as they are. - pub fn remap_bodies(&mut self, bodies: &BTreeMap) { - use FeatureKind as K; - match self { - K::Emboss { face: Some(face), .. } => mapped_name(&mut face.body, bodies), - K::Split { tool: Some(tool), .. } | K::SplitFace { tool: Some(tool), .. } | K::SurfaceTrim { tool: Some(tool), .. } => { - mapped_name(&mut tool.body, bodies) - } - _ => {} - } - match self { - K::Extrude { targets, .. } - | K::Revolve { targets, .. } - | K::Loft { targets, .. } - | K::Sweep { targets, .. } - | K::Pipe { targets, .. } - | K::Emboss { targets, .. } => { - for n in targets { - mapped_name(n, bodies); - } - } - K::Combine { target, tools, .. } => { - mapped_name(target, bodies); - for n in tools { - mapped_name(n, bodies); - } - } - K::Pattern { bodies: list, .. } - | K::Mirror { bodies: list, .. } - | K::Scale { bodies: list, .. } - | K::BoundingSolid { bodies: list, .. } - | K::Align { bodies: list, .. } - | K::Remove { bodies: list } - | K::Stitch { bodies: list, .. } - | K::Move { bodies: list, .. } - | K::BodyTransfer { bodies: list, .. } => { - for n in list { - mapped_name(n, bodies); - } - } - K::Thicken { bodies: list, targets, .. } => { - for n in list.iter_mut().chain(targets) { - mapped_name(n, bodies); - } - } - K::BoundaryFill { tools, .. } => { - for n in tools { - mapped_name(n, bodies); - } - } - K::Fillet { body, .. } - | K::Chamfer { body, .. } - | K::Shell { body, .. } - | K::Draft { body, .. } - | K::SplitFace { body, .. } - | K::OffsetFace { body, .. } - | K::DeleteFace { body, .. } - | K::SurfaceOffset { body, .. } - | K::ReplaceFace { body, .. } - | K::SheetFlange { body, .. } - | K::SheetHem { body, .. } - | K::SheetFold { body, .. } - | K::SheetUnfold { body, .. } - | K::Boss { body, .. } - | K::Lip { body, .. } - | K::SnapFit { body, .. } - | K::Rest { body, .. } - | K::Patch { body, .. } => { - if let Some(n) = body { - mapped_name(n, bodies); - } - } - K::Split { body, .. } - | K::SheetConvert { body, .. } - | K::SurfaceExtend { body, .. } - | K::SurfaceTrim { body, .. } - | K::Unstitch { body, .. } - | K::ReverseNormal { body, .. } - | K::MeshConvert { body, .. } => mapped_name(body, bodies), - _ => {} - } - } -} - impl crate::Feature { /// Clone identities through typed reference fields. Sketch-local entity IDs stay local. pub fn remap_clone( diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index e156f0cb..dc50665d 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -1294,74 +1294,6 @@ mod point_expr { } impl FeatureKind { - /// Sketches the feature is built from (profiles, paths, points): deleting one of them - /// deletes the feature. Every kind is listed, so a new one has to say what it uses. - pub fn input_sketches(&self) -> Vec { - use FeatureKind as K; - match self { - K::Extrude { sketch, .. } - | K::SurfaceExtrude { sketch, .. } - | K::SurfaceRevolve { sketch, .. } - | K::Revolve { sketch, .. } - | K::Emboss { sketch, .. } - | K::Rib { sketch, .. } - | K::SheetBase { sketch, .. } - | K::SheetContour { sketch, .. } => vec![*sketch], - K::Sweep { sketch, path_sketch, .. } | K::SurfaceSweep { sketch, path_sketch, .. } => vec![*sketch, *path_sketch], - K::Pipe { path_sketch, .. } | K::Pattern { pattern: PatternKind::Path { path_sketch, .. }, .. } => vec![*path_sketch], - K::Loft { sections, .. } => sections.iter().map(|s| s.sketch).collect(), - K::SurfaceLoft { sections } => sections.iter().map(|s| s.sketch).collect(), - K::SheetFold { sketch, .. } | K::SplitFace { sketch, .. } | K::Patch { sketch, .. } => sketch.iter().copied().collect(), - K::Hole { points, .. } => points.iter().map(|p| p.sketch).collect(), - K::Sketch { .. } - | K::Pattern { .. } - | K::Fillet { .. } - | K::Chamfer { .. } - | K::Box { .. } - | K::Cylinder { .. } - | K::Sphere { .. } - | K::Torus { .. } - | K::Combine { .. } - | K::Mirror { .. } - | K::Thread { .. } - | K::Shell { .. } - | K::Draft { .. } - | K::ConstructionPlane { .. } - | K::Split { .. } - | K::ConstructionAxis { .. } - | K::ConstructionPoint { .. } - | K::Scale { .. } - | K::OffsetFace { .. } - | K::DeleteFace { .. } - | K::SurfaceOffset { .. } - | K::BoundingSolid { .. } - | K::ReplaceFace { .. } - | K::Align { .. } - | K::Coil { .. } - | K::SheetFlange { .. } - | K::SheetHem { .. } - | K::SheetUnfold { .. } - | K::SheetConvert { .. } - | K::Boss { .. } - | K::Lip { .. } - | K::SnapFit { .. } - | K::Rest { .. } - | K::BoundaryFill { .. } - | K::Remove { .. } - | K::Stitch { .. } - | K::Thicken { .. } - | K::SurfaceExtend { .. } - | K::SurfaceTrim { .. } - | K::Unstitch { .. } - | K::ReverseNormal { .. } - | K::Move { .. } - | K::Import { .. } - | K::MeshImport { .. } - | K::MeshConvert { .. } - | K::BodyTransfer { .. } => Vec::new(), - } - } - /// A sheet metal feature (its result depends on the sheet metal rules). pub fn is_plastic(&self) -> bool { matches!(self, FeatureKind::Boss { .. } | FeatureKind::Lip { .. } | FeatureKind::SnapFit { .. } | FeatureKind::Rest { .. }) diff --git a/crates/doc/src/feature_refs.rs b/crates/doc/src/feature_refs.rs new file mode 100644 index 00000000..e22795ec --- /dev/null +++ b/crates/doc/src/feature_refs.rs @@ -0,0 +1,271 @@ +//! References from a feature to the rest of the design: the sketches it is built from (by id), +//! the features it names (patterned and mirrored features, the construction plane a plane is +//! built on) and the bodies it works on (by name). One walk, written once for `&` and `&mut`, +//! behind `input_sketches`, `remap_refs` and `remap_bodies`. Every variant is listed, so a new +//! one has to say what it refers to. + +use std::collections::BTreeMap; + +use crate::{FeatureKind, PatternKind, PlaneRef}; + +/// A reference a feature holds: `S` is `&u64` or `&mut u64` (a sketch id), `N` is `&String` or +/// `&mut String` (a feature or body name). +pub(crate) enum Ref { + Sketch(S), + Feature(N), + Body(N), +} + +/// The construction plane a plane is built on (through offsets and angles), for `&` and `&mut` +/// (`$m` is `mut` or nothing). Nesting is capped like every walk over a plane. +macro_rules! plane_walk { + ($name:ident $(, $m:tt)?) => { + fn $name<'a>(mut p: &'a $($m)? PlaneRef, f: &mut impl FnMut(Ref<&'a $($m)? u64, &'a $($m)? String>)) { + for _ in 0..64 { + match p { + PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => p = base, + PlaneRef::Construction { name } => return f(Ref::Feature(name)), + _ => return, + } + } + } + }; +} + +plane_walk!(plane_refs); +plane_walk!(plane_refs_mut, mut); + +/// Every reference of a feature, written once for `&` and `&mut`: `$plane` is the matching plane +/// walk, `$m` is `mut` or nothing. +macro_rules! feature_walk { + ($name:ident, $plane:ident $(, $m:tt)?) => { + pub(crate) fn $name<'a>(&'a $($m)? self, f: &mut impl FnMut(Ref<&'a $($m)? u64, &'a $($m)? String>)) { + use Ref::{Body as B, Feature as F, Sketch as S}; + match self { + FeatureKind::Sketch { plane, .. } | FeatureKind::ConstructionPlane { plane } => $plane(plane, f), + FeatureKind::Extrude { sketch, targets, .. } | FeatureKind::Revolve { sketch, targets, .. } => { + f(S(sketch)); + for b in targets { + f(B(b)); + } + } + FeatureKind::Emboss { sketch, targets, face, .. } => { + f(S(sketch)); + if let Some(face) = face { + f(B(& $($m)? face.body)); + } + for b in targets { + f(B(b)); + } + } + FeatureKind::SurfaceExtrude { sketch, .. } + | FeatureKind::SurfaceRevolve { sketch, .. } + | FeatureKind::Rib { sketch, .. } + | FeatureKind::SheetBase { sketch, .. } + | FeatureKind::SheetContour { sketch, .. } => f(S(sketch)), + FeatureKind::SurfaceSweep { sketch, path_sketch, .. } => { + f(S(sketch)); + f(S(path_sketch)); + } + FeatureKind::SurfaceLoft { sections } => { + for s in sections { + f(S(& $($m)? s.sketch)); + } + } + FeatureKind::Sweep { sketch, path_sketch, targets, .. } => { + f(S(sketch)); + f(S(path_sketch)); + for b in targets { + f(B(b)); + } + } + FeatureKind::Pipe { path_sketch, targets, .. } => { + f(S(path_sketch)); + for b in targets { + f(B(b)); + } + } + FeatureKind::Loft { sections, targets, .. } => { + for s in sections { + f(S(& $($m)? s.sketch)); + } + for b in targets { + f(B(b)); + } + } + FeatureKind::Coil { targets, .. } => { + for b in targets { + f(B(b)); + } + } + FeatureKind::Hole { points, .. } => { + if let Some(p) = points { + f(S(& $($m)? p.sketch)); + } + } + FeatureKind::Pattern { features, pattern, bodies } => { + for n in features { + f(F(n)); + } + if let PatternKind::Path { path_sketch, .. } = pattern { + f(S(path_sketch)); + } + for b in bodies { + f(B(b)); + } + } + FeatureKind::Mirror { features, plane, bodies, .. } => { + for n in features { + f(F(n)); + } + $plane(plane, f); + for b in bodies { + f(B(b)); + } + } + FeatureKind::Split { body, plane, tool } | FeatureKind::SurfaceTrim { body, plane, tool, .. } => { + f(B(body)); + $plane(plane, f); + if let Some(t) = tool { + f(B(& $($m)? t.body)); + } + } + FeatureKind::SplitFace { body, plane, tool, sketch, .. } => { + $plane(plane, f); + if let Some(s) = sketch { + f(S(s)); + } + if let Some(b) = body { + f(B(b)); + } + if let Some(t) = tool { + f(B(& $($m)? t.body)); + } + } + FeatureKind::Draft { neutral: plane, body, .. } | FeatureKind::ReplaceFace { target: plane, body, .. } => { + $plane(plane, f); + if let Some(b) = body { + f(B(b)); + } + } + FeatureKind::SheetFold { sketch, body, .. } | FeatureKind::Patch { sketch, body, .. } => { + if let Some(s) = sketch { + f(S(s)); + } + if let Some(b) = body { + f(B(b)); + } + } + FeatureKind::Fillet { body, .. } + | FeatureKind::Chamfer { body, .. } + | FeatureKind::Shell { body, .. } + | FeatureKind::OffsetFace { body, .. } + | FeatureKind::DeleteFace { body, .. } + | FeatureKind::SheetFlange { body, .. } + | FeatureKind::SheetHem { body, .. } + | FeatureKind::SheetUnfold { body, .. } + | FeatureKind::Boss { body, .. } + | FeatureKind::Lip { body, .. } + | FeatureKind::SnapFit { body, .. } + | FeatureKind::Rest { body, .. } + | FeatureKind::SurfaceOffset { body, .. } => { + if let Some(b) = body { + f(B(b)); + } + } + FeatureKind::SheetConvert { body, .. } + | FeatureKind::SurfaceExtend { body, .. } + | FeatureKind::Unstitch { body, .. } + | FeatureKind::ReverseNormal { body, .. } + | FeatureKind::MeshConvert { body, .. } => f(B(body)), + FeatureKind::Combine { target, tools, .. } => { + f(B(target)); + for b in tools { + f(B(b)); + } + } + FeatureKind::Thicken { bodies, targets, .. } => { + for b in bodies { + f(B(b)); + } + for b in targets { + f(B(b)); + } + } + FeatureKind::BodyTransfer { bodies, .. } + | FeatureKind::Scale { bodies, .. } + | FeatureKind::BoundingSolid { bodies, .. } + | FeatureKind::Align { bodies, .. } + | FeatureKind::Remove { bodies } + | FeatureKind::Stitch { bodies, .. } + | FeatureKind::Move { bodies, .. } => { + for b in bodies { + f(B(b)); + } + } + FeatureKind::BoundaryFill { tools, .. } => { + for b in tools { + f(B(b)); + } + } + // Construction axes and points refer to geometry, which copies remap on their own + // (`Feature::remap_clone`). + FeatureKind::ConstructionAxis { .. } + | FeatureKind::ConstructionPoint { .. } + | FeatureKind::Box { .. } + | FeatureKind::Cylinder { .. } + | FeatureKind::Sphere { .. } + | FeatureKind::Torus { .. } + | FeatureKind::Thread { .. } + | FeatureKind::Import { .. } + | FeatureKind::MeshImport { .. } => {} + } + } + }; +} + +impl FeatureKind { + feature_walk!(walk_refs, plane_refs); + feature_walk!(walk_refs_mut, plane_refs_mut, mut); + + /// Sketches the feature is built from (profiles, paths, points): deleting one of them + /// deletes the feature. + pub fn input_sketches(&self) -> Vec { + let mut v = Vec::new(); + self.walk_refs(&mut |r| { + if let Ref::Sketch(id) = r { + v.push(*id); + } + }); + v + } + + /// Point references to sketches (by id) and features (by name) at their copies, or at a + /// feature's new name (`timeline.rename`). + pub fn remap_refs(&mut self, sketches: &BTreeMap, names: &BTreeMap) { + self.walk_refs_mut(&mut |r| match r { + Ref::Sketch(id) => { + if let Some(n) = sketches.get(id) { + *id = *n; + } + } + Ref::Feature(name) => { + if let Some(n) = names.get(name) { + *name = n.clone(); + } + } + Ref::Body(_) => {} + }); + } + + /// Point references to bodies (by name) at their copies. + pub fn remap_bodies(&mut self, bodies: &BTreeMap) { + self.walk_refs_mut(&mut |r| { + if let Ref::Body(name) = r + && let Some(n) = bodies.get(name) + { + *name = n.clone(); + } + }); + } +} diff --git a/crates/doc/src/feature_walk_tests.rs b/crates/doc/src/feature_walk_tests.rs index 6bebf252..95c64a30 100644 --- a/crates/doc/src/feature_walk_tests.rs +++ b/crates/doc/src/feature_walk_tests.rs @@ -1,161 +1,22 @@ -//! Golden record of what the walk over a feature's expressions reports, for every variant (and -//! every fillet style, pattern, hole and construction definition): `inputs` (label, expression, -//! kind, in order: the order names the `dN` parameters) and `expressions` (every expression, -//! placement points included). Each expression field holds its own name, so a moved, missing or -//! relabelled input shows in the diff. The records live in `feature_walk_tests/*.txt`; to record -//! an intended change, run the tests with `FEATURE_WALK_BLESS=1`. +//! Golden records of what the walks over a feature report, for every variant (and every fillet +//! style, pattern, hole and construction definition): +//! - expressions: `inputs` (label, expression, kind, in order: the order names the `dN` +//! parameters) and `expressions` (every expression, placement points included). Each +//! expression field holds its own name, so a moved, missing or relabelled input shows; +//! - references: the sketches a feature is built from, and the fields `remap_refs` and +//! `remap_bodies` rewrite when everything is copied under new ids and names. +//! +//! The records live in `feature_walk_tests/*.txt`; to record an intended change, run the tests +//! with `FEATURE_WALK_BLESS=1`. -use serde_json::{Value, json}; +mod fixtures; + +use std::collections::BTreeMap; + +use fixtures::{changed, fixtures, with_refs}; use crate::FeatureKind; -fn plane(chain: &str) -> Value { - json!({"type": "at_angle", "angle": format!("{chain}.angle"), "axis_origin": [0, 0, 0], "axis_dir": [1, 0, 0], - "base": {"type": "offset", "distance": format!("{chain}.offset"), "base": {"type": "origin", "name": "XY"}}}) -} - -fn geo_plane(chain: &str) -> Value { - json!({"type": "plane", "plane": plane(chain)}) -} - -fn along(chain: &str) -> Value { - json!({"type": "along_path", "path": {"type": "edge", "at": [0, 0, 0]}, "t": format!("{chain}.t")}) -} - -fn fixtures() -> Vec<(&'static str, Value)> { - let o = [0, 0, 0]; - let sk = serde_json::to_value(solidcraft_sketch::Sketch::new()).unwrap(); - let extent = |n: &str| { - json!({"distance": format!("{n}.distance"), "distance2": format!("{n}.distance2"), "start_offset": format!("{n}.start_offset"), - "taper": format!("{n}.taper"), "to_offset": format!("{n}.to_offset")}) - }; - let fillet = |style: Value| json!({"type": "fillet", "edges": [o], "radius": "fillet.radius", "style": style}); - let pattern = |p: Value| json!({"type": "pattern", "features": ["F"], "pattern": p}); - let hole = - |h: Value| json!({"type": "hole", "position": o, "direction": [0, 0, 1], "diameter": "hole.diameter", "depth": "hole.depth", "hole": h}); - vec![ - ( - "body_transfer", - json!({"type": "body_transfer", "bodies": ["B"], "from": 0, "to": 1, "transform": [[1,0,0,0],[0,1,0,0],[0,0,1,0],[0,0,0,1]]}), - ), - ("sketch", json!({"type": "sketch", "plane": plane("sketch"), "sketch": sk.clone()})), - ("sketch_along_path", json!({"type": "sketch", "plane": along("sketch"), "sketch": sk})), - ("extrude", json!({"type": "extrude", "sketch": 1, "profiles": {"type": "all"}, "extent": extent("extrude")})), - ( - "revolve", - json!({"type": "revolve", "sketch": 1, "profiles": {"type": "all"}, "axis": {"type": "world", "axis": "X"}, "angle": "revolve.angle", "angle2": "revolve.angle2"}), - ), - ("fillet_constant", fillet(json!({"type": "constant"}))), - ("fillet_chord", fillet(json!({"type": "chord"}))), - ("fillet_curvature", fillet(json!({"type": "curvature"}))), - ("fillet_variable", fillet(json!({"type": "variable", "radius2": "fillet.radius2", "start": o, "mid": [[0.5, "fillet.mid"]]}))), - ("fillet_spline", fillet(json!({"type": "spline", "start": o, "knots": [0.0, 1.0], "radii": ["fillet.r1", "fillet.r2", "fillet.r3"]}))), - ( - "chamfer", - json!({"type": "chamfer", "edges": [o], "distance": "chamfer.distance", "distance2": "chamfer.distance2", "angle": "chamfer.angle"}), - ), - ("box", json!({"type": "box", "corner": ["box.x", "box.y", "box.z"], "length": "box.length", "width": "box.width", "height": "box.height"})), - ("cylinder", json!({"type": "cylinder", "base": ["cyl.x", "cyl.y", "cyl.z"], "radius": "cyl.radius", "height": "cyl.height"})), - ("sphere", json!({"type": "sphere", "center": ["sph.x", "sph.y", "sph.z"], "radius": "sph.radius"})), - ("torus", json!({"type": "torus", "center": ["tor.x", "tor.y", "tor.z"], "major": "tor.major", "minor": "tor.minor"})), - ("combine", json!({"type": "combine", "target": "A", "tools": ["B"], "operation": "join"})), - ( - "pattern_rectangular", - pattern( - json!({"type": "rectangular", "dir1": [1, 0, 0], "count1": "rect.count1", "spacing1": "rect.spacing1", "dir2": [0, 1, 0], "count2": "rect.count2", "spacing2": "rect.spacing2"}), - ), - ), - ("pattern_circular", pattern(json!({"type": "circular", "origin": o, "axis": [0, 0, 1], "count": "circ.count", "angle": "circ.angle"}))), - ("pattern_path", pattern(json!({"type": "path", "path_sketch": 1, "path": ["l1"], "count": "path.count", "spacing": "path.spacing"}))), - ("mirror", json!({"type": "mirror", "features": ["F"], "plane": plane("mirror")})), - ("hole_simple", hole(json!({"type": "simple"}))), - ("hole_drilled", hole(json!({"type": "drilled", "tip_angle": "hole.tip_angle"}))), - ("hole_counterbore", hole(json!({"type": "counterbore", "cb_diameter": "hole.cb_diameter", "cb_depth": "hole.cb_depth"}))), - ("hole_countersink", hole(json!({"type": "countersink", "cs_diameter": "hole.cs_diameter", "cs_angle": "hole.cs_angle"}))), - ("thread", json!({"type": "thread", "face": o, "length": "thread.length"})), - ("loft", json!({"type": "loft", "sections": []})), - ("sweep", json!({"type": "sweep", "sketch": 1, "profiles": {"type": "all"}, "path_sketch": 2, "path": ["l1"]})), - ("shell", json!({"type": "shell", "faces": [o], "thickness": "shell.thickness"})), - ("draft", json!({"type": "draft", "faces": [o], "angle": "draft.angle", "neutral": plane("draft_neutral"), "pull": [0, 0, 1]})), - ("construction_plane", json!({"type": "construction_plane", "plane": plane("cplane")})), - ("split", json!({"type": "split", "body": "B", "plane": plane("split")})), - ( - "construction_axis", - json!({"type": "construction_axis", "def": {"type": "two_planes", "a": geo_plane("axis_a"), "b": geo_plane("axis_b")}}), - ), - ( - "construction_point_planes", - json!({"type": "construction_point", "def": {"type": "three_planes", "a": geo_plane("pt_a"), "b": geo_plane("pt_b"), "c": geo_plane("pt_c")}}), - ), - ( - "construction_point_along", - json!({"type": "construction_point", "def": {"type": "along_path", "path": {"type": "edge", "at": o}, "t": "pt.t"}}), - ), - ("split_face", json!({"type": "split_face", "faces": [o], "plane": plane("split_face")})), - ("scale", json!({"type": "scale", "bodies": ["B"], "factor": "scale.factor", "factors": ["scale.x", "scale.y", "scale.z"]})), - ("offset_face", json!({"type": "offset_face", "faces": [o], "distance": "offset_face.distance"})), - ("delete_face", json!({"type": "delete_face", "faces": [o]})), - ("bounding_solid", json!({"type": "bounding_solid", "bodies": ["B"], "margin": "bounding.margin"})), - ("pipe", json!({"type": "pipe", "path_sketch": 1, "path": ["l1"], "diameter": "pipe.diameter", "wall": "pipe.wall"})), - ("emboss", json!({"type": "emboss", "sketch": 1, "profiles": {"type": "all"}, "depth": "emboss.depth"})), - ("rib", json!({"type": "rib", "sketch": 1, "curves": ["l1"], "thickness": "rib.thickness", "depth": "rib.depth"})), - ("replace_face", json!({"type": "replace_face", "faces": [o], "target": plane("replace_face")})), - ("align", json!({"type": "align", "bodies": ["B"], "from": o, "to": o})), - ( - "coil", - json!({"type": "coil", "base": ["coil.x", "coil.y", "coil.z"], "diameter": "coil.diameter", "pitch": "coil.pitch", "turns": "coil.turns", "section_size": "coil.section_size", "start_angle": "coil.start_angle"}), - ), - ("sheet_base", json!({"type": "sheet_base", "sketch": 1, "profiles": {"type": "all"}})), - ("sheet_contour", json!({"type": "sheet_contour", "sketch": 1, "curves": ["l1"], "distance": "contour.distance"})), - ( - "sheet_flange", - json!({"type": "sheet_flange", "edges": [{"point": o, "angle": "flange.e1_angle", "height": "flange.e1_height"}, {"point": o, "angle": null, "height": "flange.e2_height"}], "height": "flange.height", "angle": "flange.angle", "radius": "flange.radius"}), - ), - ("sheet_hem", json!({"type": "sheet_hem", "edges": [o], "length": "hem.length", "gap": "hem.gap"})), - ( - "sheet_fold", - json!({"type": "sheet_fold", "a": o, "b": [1, 0, 0], "angle": "fold.angle", "radius": "fold.radius", "position": "centerline"}), - ), - ("sheet_unfold", json!({"type": "sheet_unfold"})), - ("sheet_convert", json!({"type": "sheet_convert", "body": "B", "face": o})), - ( - "boss", - json!({"type": "boss", "position": o, "diameter": "boss.diameter", "height": "boss.height", "hole_diameter": "boss.hole_diameter", "hole_depth": "boss.hole_depth", - "draft": "boss.draft", "fillet": "boss.fillet", "ribs": "boss.ribs", "rib_thickness": "boss.rib_thickness", "rib_length": "boss.rib_length", "rib_offset": "boss.rib_offset"}), - ), - ("lip", json!({"type": "lip", "face": o, "width": "lip.width", "height": "lip.height", "gap": "lip.gap"})), - ( - "snap_fit", - json!({"type": "snap_fit", "position": o, "hook": [1, 0, 0], "length": "snap.length", "thickness": "snap.thickness", "width": "snap.width", "catch_depth": "snap.catch_depth", "catch_length": "snap.catch_length"}), - ), - ( - "rest", - json!({"type": "rest", "position": o, "width": "rest.width", "length": "rest.length", "height": "rest.height", "draft": "rest.draft", "thickness": "rest.thickness"}), - ), - ("boundary_fill", json!({"type": "boundary_fill", "tools": ["B"], "cells": [o]})), - ("remove", json!({"type": "remove", "bodies": ["B"]})), - ("patch", json!({"type": "patch"})), - ("stitch", json!({"type": "stitch", "bodies": ["B"], "tolerance": "stitch.tolerance"})), - ("thicken", json!({"type": "thicken", "bodies": ["B"], "thickness": "thicken.thickness", "operation": "new_body"})), - ("surface_extrude", json!({"type": "surface_extrude", "sketch": 1, "extent": extent("surface_extrude")})), - ( - "surface_revolve", - json!({"type": "surface_revolve", "sketch": 1, "axis": {"type": "world", "axis": "X"}, "angle": "srev.angle", "angle2": "srev.angle2"}), - ), - ("surface_sweep", json!({"type": "surface_sweep", "sketch": 1, "path_sketch": 2, "path": ["l1"]})), - ("surface_loft", json!({"type": "surface_loft", "sections": []})), - ("surface_offset", json!({"type": "surface_offset", "faces": [o], "distance": "soff.distance", "tolerance": "soff.tolerance"})), - ("unstitch", json!({"type": "unstitch", "body": "B"})), - ("reverse_normal", json!({"type": "reverse_normal", "body": "B"})), - ("surface_extend", json!({"type": "surface_extend", "body": "B", "edges": [o], "distance": "extend.distance"})), - ("surface_trim", json!({"type": "surface_trim", "body": "B", "plane": plane("trim"), "keep": o})), - ("move", json!({"type": "move", "bodies": ["B"], "translate": ["move.x", "move.y", "move.z"], "angle": "move.angle"})), - ("import", json!({"type": "import", "file": "a.step", "step": ""})), - ("mesh_convert", json!({"type": "mesh_convert", "body": "B", "tolerance": "mesh_convert.tolerance"})), - ("mesh_import", json!({"type": "mesh_import", "file": "a.stl", "meshes": []})), - ] -} - /// Per fixture: its inputs, one per line (`label = expression (kind)`), then every expression. fn record() -> String { let mut out = String::new(); @@ -177,6 +38,43 @@ fn record() -> String { out } +/// Per fixture: the sketches it is built from, and the fields that copying with every sketch, +/// feature and body renamed rewrites (`remap_refs`: sketches and features; `remap_bodies`). +fn ref_record() -> String { + let sketches: BTreeMap = (1..=8).map(|i| (i, i + 100)).collect(); + let names = BTreeMap::from([("F".to_string(), "F2".to_string()), ("CP".to_string(), "CP2".to_string())]); + let bodies = BTreeMap::from([("B".to_string(), "B2".to_string())]); + let mut out = String::new(); + for (name, v) in fixtures() { + let kind: FeatureKind = serde_json::from_value(with_refs(name, v)).unwrap_or_else(|e| panic!("{name}: {e}")); + let before = serde_json::to_value(&kind).unwrap(); + let mut refs = kind.clone(); + refs.remap_refs(&sketches, &names); + let mut by_refs = Vec::new(); + changed(&before, &serde_json::to_value(&refs).unwrap(), "", &mut by_refs); + let mut body = kind.clone(); + body.remap_bodies(&bodies); + let mut by_bodies = Vec::new(); + changed(&before, &serde_json::to_value(&body).unwrap(), "", &mut by_bodies); + out += &format!("{name}\n"); + let sketches = kind.input_sketches(); + if !sketches.is_empty() { + out += &format!(" sketches: {sketches:?}\n"); + } + if !by_refs.is_empty() { + out += &format!(" remap_refs: {}\n", by_refs.join(", ")); + } + if !by_bodies.is_empty() { + out += &format!(" remap_bodies: {}\n", by_bodies.join(", ")); + } + } + out +} + +/// A variant with a field that refers to a sketch (a field named `*sketch*` holding an id, sketch +/// points or loft sections) must report it from `input_sketches`, or deleting the sketch leaves +/// the feature broken. New variants are caught by the walk's exhaustive match; this catches a +/// sketch field added to an existing variant (its fixture has to set it). /// Compares `got` with the golden file `name` in `feature_walk_tests/`, line by line. With /// `FEATURE_WALK_BLESS` set it rewrites the file instead, for an intended change (say why in the /// pull request). @@ -195,6 +93,43 @@ fn check_golden(name: &str, want: &str, got: &str) { /// Recorded from the hand-written `inputs_mut` and `expressions` the walk replaced. const GOLDEN_EXPRS: &str = include_str!("feature_walk_tests/golden_exprs.txt"); +/// Recorded from the hand-written `input_sketches`, `remap_refs` and `remap_bodies` the walk +/// replaced, with two fixes: a coil's `targets` and a surface trim's plane are remapped too. +const GOLDEN_REFS: &str = include_str!("feature_walk_tests/golden_refs.txt"); + +#[test] +fn input_sketches_lists_every_variant_with_a_sketch_field() { + let src = include_str!("document.rs"); + let body = src.split("pub enum FeatureKind {").nth(1).and_then(|s| s.split("\n}\n").next()).unwrap(); + let mut with_sketch = Vec::new(); + let mut variant = String::new(); + for line in body.lines() { + let t = line.trim(); + if line.starts_with(" ") && !line.starts_with(" ") && t.chars().next().is_some_and(|c| c.is_ascii_uppercase()) { + variant = t.split(|c: char| !c.is_alphanumeric()).next().unwrap_or("").to_lowercase(); + continue; + } + let Some((name, ty)) = t.strip_prefix("pub ").unwrap_or(t).split_once(':') else { continue }; + let refers = (name.contains("sketch") && ty.contains("u64")) || ty.contains("SketchPoints") || ty.contains("LoftSection"); + if refers && !t.starts_with("//") && !with_sketch.contains(&variant) { + with_sketch.push(variant.clone()); + } + } + assert!(with_sketch.len() >= 10, "the FeatureKind parse found too few sketch users: {with_sketch:?}"); + for v in with_sketch { + let uses = fixtures().into_iter().filter(|(_, f)| f["type"].as_str().unwrap().replace('_', "") == v).any(|(name, f)| { + let kind: FeatureKind = serde_json::from_value(with_refs(name, f)).unwrap(); + !kind.input_sketches().is_empty() + }); + assert!(uses, "FeatureKind::{v} has a sketch field but input_sketches reports no sketch for its fixtures"); + } +} + +#[test] +fn references_match_the_golden_record() { + check_golden("golden_refs.txt", GOLDEN_REFS, &ref_record()); +} + #[test] fn inputs_and_expressions_match_the_golden_record() { check_golden("golden_exprs.txt", GOLDEN_EXPRS, &record()); diff --git a/crates/doc/src/feature_walk_tests/fixtures.rs b/crates/doc/src/feature_walk_tests/fixtures.rs new file mode 100644 index 00000000..d7975d00 --- /dev/null +++ b/crates/doc/src/feature_walk_tests/fixtures.rs @@ -0,0 +1,201 @@ +//! Fixtures for the golden records in `feature_walk_tests`: one feature per variant (and per +//! fillet style, pattern, hole kind and construction definition), each expression field holding +//! its own name, plus the reference fields `with_refs` sets. + +use serde_json::{Value, json}; + +fn plane(chain: &str) -> Value { + json!({"type": "at_angle", "angle": format!("{chain}.angle"), "axis_origin": [0, 0, 0], "axis_dir": [1, 0, 0], + "base": {"type": "offset", "distance": format!("{chain}.offset"), "base": {"type": "construction", "name": "CP"}}}) +} + +fn geo_plane(chain: &str) -> Value { + json!({"type": "plane", "plane": plane(chain)}) +} + +fn along(chain: &str) -> Value { + json!({"type": "along_path", "path": {"type": "edge", "at": [0, 0, 0]}, "t": format!("{chain}.t")}) +} + +pub(super) fn fixtures() -> Vec<(&'static str, Value)> { + let o = [0, 0, 0]; + let sk = serde_json::to_value(solidcraft_sketch::Sketch::new()).unwrap(); + let extent = |n: &str| { + json!({"distance": format!("{n}.distance"), "distance2": format!("{n}.distance2"), "start_offset": format!("{n}.start_offset"), + "taper": format!("{n}.taper"), "to_offset": format!("{n}.to_offset")}) + }; + let fillet = |style: Value| json!({"type": "fillet", "edges": [o], "radius": "fillet.radius", "style": style}); + let pattern = |p: Value| json!({"type": "pattern", "features": ["F"], "pattern": p}); + let hole = + |h: Value| json!({"type": "hole", "position": o, "direction": [0, 0, 1], "diameter": "hole.diameter", "depth": "hole.depth", "hole": h}); + vec![ + ( + "body_transfer", + json!({"type": "body_transfer", "bodies": ["B"], "from": 0, "to": 1, "transform": [[1,0,0,0],[0,1,0,0],[0,0,1,0],[0,0,0,1]]}), + ), + ("sketch", json!({"type": "sketch", "plane": plane("sketch"), "sketch": sk.clone()})), + ("sketch_along_path", json!({"type": "sketch", "plane": along("sketch"), "sketch": sk})), + ("extrude", json!({"type": "extrude", "sketch": 1, "profiles": {"type": "all"}, "extent": extent("extrude")})), + ( + "revolve", + json!({"type": "revolve", "sketch": 1, "profiles": {"type": "all"}, "axis": {"type": "world", "axis": "X"}, "angle": "revolve.angle", "angle2": "revolve.angle2"}), + ), + ("fillet_constant", fillet(json!({"type": "constant"}))), + ("fillet_chord", fillet(json!({"type": "chord"}))), + ("fillet_curvature", fillet(json!({"type": "curvature"}))), + ("fillet_variable", fillet(json!({"type": "variable", "radius2": "fillet.radius2", "start": o, "mid": [[0.5, "fillet.mid"]]}))), + ("fillet_spline", fillet(json!({"type": "spline", "start": o, "knots": [0.0, 1.0], "radii": ["fillet.r1", "fillet.r2", "fillet.r3"]}))), + ( + "chamfer", + json!({"type": "chamfer", "edges": [o], "distance": "chamfer.distance", "distance2": "chamfer.distance2", "angle": "chamfer.angle"}), + ), + ("box", json!({"type": "box", "corner": ["box.x", "box.y", "box.z"], "length": "box.length", "width": "box.width", "height": "box.height"})), + ("cylinder", json!({"type": "cylinder", "base": ["cyl.x", "cyl.y", "cyl.z"], "radius": "cyl.radius", "height": "cyl.height"})), + ("sphere", json!({"type": "sphere", "center": ["sph.x", "sph.y", "sph.z"], "radius": "sph.radius"})), + ("torus", json!({"type": "torus", "center": ["tor.x", "tor.y", "tor.z"], "major": "tor.major", "minor": "tor.minor"})), + ("combine", json!({"type": "combine", "target": "A", "tools": ["B"], "operation": "join"})), + ( + "pattern_rectangular", + pattern( + json!({"type": "rectangular", "dir1": [1, 0, 0], "count1": "rect.count1", "spacing1": "rect.spacing1", "dir2": [0, 1, 0], "count2": "rect.count2", "spacing2": "rect.spacing2"}), + ), + ), + ("pattern_circular", pattern(json!({"type": "circular", "origin": o, "axis": [0, 0, 1], "count": "circ.count", "angle": "circ.angle"}))), + ("pattern_path", pattern(json!({"type": "path", "path_sketch": 1, "path": ["l1"], "count": "path.count", "spacing": "path.spacing"}))), + ("mirror", json!({"type": "mirror", "features": ["F"], "plane": plane("mirror")})), + ("hole_simple", hole(json!({"type": "simple"}))), + ("hole_drilled", hole(json!({"type": "drilled", "tip_angle": "hole.tip_angle"}))), + ("hole_counterbore", hole(json!({"type": "counterbore", "cb_diameter": "hole.cb_diameter", "cb_depth": "hole.cb_depth"}))), + ("hole_countersink", hole(json!({"type": "countersink", "cs_diameter": "hole.cs_diameter", "cs_angle": "hole.cs_angle"}))), + ("thread", json!({"type": "thread", "face": o, "length": "thread.length"})), + ("loft", json!({"type": "loft", "sections": []})), + ("sweep", json!({"type": "sweep", "sketch": 1, "profiles": {"type": "all"}, "path_sketch": 2, "path": ["l1"]})), + ("shell", json!({"type": "shell", "faces": [o], "thickness": "shell.thickness"})), + ("draft", json!({"type": "draft", "faces": [o], "angle": "draft.angle", "neutral": plane("draft_neutral"), "pull": [0, 0, 1]})), + ("construction_plane", json!({"type": "construction_plane", "plane": plane("cplane")})), + ("split", json!({"type": "split", "body": "B", "plane": plane("split")})), + ( + "construction_axis", + json!({"type": "construction_axis", "def": {"type": "two_planes", "a": geo_plane("axis_a"), "b": geo_plane("axis_b")}}), + ), + ( + "construction_point_planes", + json!({"type": "construction_point", "def": {"type": "three_planes", "a": geo_plane("pt_a"), "b": geo_plane("pt_b"), "c": geo_plane("pt_c")}}), + ), + ( + "construction_point_along", + json!({"type": "construction_point", "def": {"type": "along_path", "path": {"type": "edge", "at": o}, "t": "pt.t"}}), + ), + ("split_face", json!({"type": "split_face", "faces": [o], "plane": plane("split_face")})), + ("scale", json!({"type": "scale", "bodies": ["B"], "factor": "scale.factor", "factors": ["scale.x", "scale.y", "scale.z"]})), + ("offset_face", json!({"type": "offset_face", "faces": [o], "distance": "offset_face.distance"})), + ("delete_face", json!({"type": "delete_face", "faces": [o]})), + ("bounding_solid", json!({"type": "bounding_solid", "bodies": ["B"], "margin": "bounding.margin"})), + ("pipe", json!({"type": "pipe", "path_sketch": 1, "path": ["l1"], "diameter": "pipe.diameter", "wall": "pipe.wall"})), + ("emboss", json!({"type": "emboss", "sketch": 1, "profiles": {"type": "all"}, "depth": "emboss.depth"})), + ("rib", json!({"type": "rib", "sketch": 1, "curves": ["l1"], "thickness": "rib.thickness", "depth": "rib.depth"})), + ("replace_face", json!({"type": "replace_face", "faces": [o], "target": plane("replace_face")})), + ("align", json!({"type": "align", "bodies": ["B"], "from": o, "to": o})), + ( + "coil", + json!({"type": "coil", "base": ["coil.x", "coil.y", "coil.z"], "diameter": "coil.diameter", "pitch": "coil.pitch", "turns": "coil.turns", "section_size": "coil.section_size", "start_angle": "coil.start_angle"}), + ), + ("sheet_base", json!({"type": "sheet_base", "sketch": 1, "profiles": {"type": "all"}})), + ("sheet_contour", json!({"type": "sheet_contour", "sketch": 1, "curves": ["l1"], "distance": "contour.distance"})), + ( + "sheet_flange", + json!({"type": "sheet_flange", "edges": [{"point": o, "angle": "flange.e1_angle", "height": "flange.e1_height"}, {"point": o, "angle": null, "height": "flange.e2_height"}], "height": "flange.height", "angle": "flange.angle", "radius": "flange.radius"}), + ), + ("sheet_hem", json!({"type": "sheet_hem", "edges": [o], "length": "hem.length", "gap": "hem.gap"})), + ( + "sheet_fold", + json!({"type": "sheet_fold", "a": o, "b": [1, 0, 0], "angle": "fold.angle", "radius": "fold.radius", "position": "centerline"}), + ), + ("sheet_unfold", json!({"type": "sheet_unfold"})), + ("sheet_convert", json!({"type": "sheet_convert", "body": "B", "face": o})), + ( + "boss", + json!({"type": "boss", "position": o, "diameter": "boss.diameter", "height": "boss.height", "hole_diameter": "boss.hole_diameter", "hole_depth": "boss.hole_depth", + "draft": "boss.draft", "fillet": "boss.fillet", "ribs": "boss.ribs", "rib_thickness": "boss.rib_thickness", "rib_length": "boss.rib_length", "rib_offset": "boss.rib_offset"}), + ), + ("lip", json!({"type": "lip", "face": o, "width": "lip.width", "height": "lip.height", "gap": "lip.gap"})), + ( + "snap_fit", + json!({"type": "snap_fit", "position": o, "hook": [1, 0, 0], "length": "snap.length", "thickness": "snap.thickness", "width": "snap.width", "catch_depth": "snap.catch_depth", "catch_length": "snap.catch_length"}), + ), + ( + "rest", + json!({"type": "rest", "position": o, "width": "rest.width", "length": "rest.length", "height": "rest.height", "draft": "rest.draft", "thickness": "rest.thickness"}), + ), + ("boundary_fill", json!({"type": "boundary_fill", "tools": ["B"], "cells": [o]})), + ("remove", json!({"type": "remove", "bodies": ["B"]})), + ("patch", json!({"type": "patch"})), + ("stitch", json!({"type": "stitch", "bodies": ["B"], "tolerance": "stitch.tolerance"})), + ("thicken", json!({"type": "thicken", "bodies": ["B"], "thickness": "thicken.thickness", "operation": "new_body"})), + ("surface_extrude", json!({"type": "surface_extrude", "sketch": 1, "extent": extent("surface_extrude")})), + ( + "surface_revolve", + json!({"type": "surface_revolve", "sketch": 1, "axis": {"type": "world", "axis": "X"}, "angle": "srev.angle", "angle2": "srev.angle2"}), + ), + ("surface_sweep", json!({"type": "surface_sweep", "sketch": 1, "path_sketch": 2, "path": ["l1"]})), + ("surface_loft", json!({"type": "surface_loft", "sections": []})), + ("surface_offset", json!({"type": "surface_offset", "faces": [o], "distance": "soff.distance", "tolerance": "soff.tolerance"})), + ("unstitch", json!({"type": "unstitch", "body": "B"})), + ("reverse_normal", json!({"type": "reverse_normal", "body": "B"})), + ("surface_extend", json!({"type": "surface_extend", "body": "B", "edges": [o], "distance": "extend.distance"})), + ("surface_trim", json!({"type": "surface_trim", "body": "B", "plane": plane("trim"), "keep": o})), + ("move", json!({"type": "move", "bodies": ["B"], "translate": ["move.x", "move.y", "move.z"], "angle": "move.angle"})), + ("import", json!({"type": "import", "file": "a.step", "step": ""})), + ("mesh_convert", json!({"type": "mesh_convert", "body": "B", "tolerance": "mesh_convert.tolerance"})), + ("mesh_import", json!({"type": "mesh_import", "file": "a.stl", "meshes": []})), + ] +} + +/// The reference fields a fixture leaves out (they don't change its expressions), set here so +/// every sketch, feature and body reference is exercised: sketches 1–8, feature `F`, +/// construction plane `CP`, body `B`. +pub(super) fn with_refs(name: &str, mut v: Value) -> Value { + let o = [0, 0, 0]; + let tool = json!({"body": "B", "point": o}); + let extra = match name { + "extrude" | "revolve" | "sweep" | "pipe" | "coil" | "thicken" => json!({"targets": ["B"]}), + "fillet_constant" | "fillet_chord" | "fillet_curvature" | "fillet_variable" | "fillet_spline" | "chamfer" | "shell" | "draft" + | "offset_face" | "delete_face" | "replace_face" | "sheet_flange" | "sheet_hem" | "sheet_unfold" | "boss" | "lip" | "snap_fit" | "rest" + | "surface_offset" => { + json!({"body": "B"}) + } + "combine" => json!({"target": "B", "tools": ["B"]}), + "pattern_rectangular" | "pattern_circular" | "pattern_path" | "mirror" => json!({"bodies": ["B"]}), + "hole_simple" | "hole_drilled" | "hole_counterbore" | "hole_countersink" => json!({"points": {"sketch": 3, "ids": ["p"]}}), + "loft" => json!({"sections": [{"sketch": 4, "profiles": {"type": "all"}}, {"sketch": 5, "profiles": {"type": "all"}}], "targets": ["B"]}), + "split" | "surface_trim" => json!({"tool": tool}), + "surface_loft" => json!({"sections": [{"sketch": 4}, {"sketch": 5}]}), + "split_face" => json!({"body": "B", "tool": tool, "sketch": 6}), + "emboss" => json!({"face": {"body": "B", "at": o}, "targets": ["B"]}), + "sheet_fold" => json!({"sketch": 7, "body": "B"}), + "patch" => json!({"sketch": 8, "body": "B"}), + _ => json!({}), + }; + if let (Some(o), Some(e)) = (v.as_object_mut(), extra.as_object()) { + o.extend(e.clone()); + } + v +} + +/// The paths of the leaves that differ between two JSON values. +pub(super) fn changed(a: &Value, b: &Value, path: &str, out: &mut Vec) { + match (a, b) { + (Value::Object(x), Value::Object(y)) => { + for (k, v) in x { + changed(v, y.get(k).unwrap_or(&Value::Null), &format!("{path}.{k}"), out); + } + } + (Value::Array(x), Value::Array(y)) => { + for (i, v) in x.iter().enumerate() { + changed(v, y.get(i).unwrap_or(&Value::Null), &format!("{path}[{i}]"), out); + } + } + _ if a != b => out.push(path.trim_start_matches('.').to_string()), + _ => {} + } +} diff --git a/crates/doc/src/feature_walk_tests/golden_refs.txt b/crates/doc/src/feature_walk_tests/golden_refs.txt new file mode 100644 index 00000000..421ffeb0 --- /dev/null +++ b/crates/doc/src/feature_walk_tests/golden_refs.txt @@ -0,0 +1,175 @@ +body_transfer + remap_bodies: bodies[0] +sketch + remap_refs: plane.base.base.name +sketch_along_path +extrude + sketches: [1] + remap_refs: sketch + remap_bodies: targets[0] +revolve + sketches: [1] + remap_refs: sketch + remap_bodies: targets[0] +fillet_constant + remap_bodies: body +fillet_chord + remap_bodies: body +fillet_curvature + remap_bodies: body +fillet_variable + remap_bodies: body +fillet_spline + remap_bodies: body +chamfer + remap_bodies: body +box +cylinder +sphere +torus +combine + remap_bodies: target, tools[0] +pattern_rectangular + remap_refs: features[0] + remap_bodies: bodies[0] +pattern_circular + remap_refs: features[0] + remap_bodies: bodies[0] +pattern_path + sketches: [1] + remap_refs: features[0], pattern.path_sketch + remap_bodies: bodies[0] +mirror + remap_refs: features[0], plane.base.base.name + remap_bodies: bodies[0] +hole_simple + sketches: [3] + remap_refs: points.sketch +hole_drilled + sketches: [3] + remap_refs: points.sketch +hole_counterbore + sketches: [3] + remap_refs: points.sketch +hole_countersink + sketches: [3] + remap_refs: points.sketch +thread +loft + sketches: [4, 5] + remap_refs: sections[0].sketch, sections[1].sketch + remap_bodies: targets[0] +sweep + sketches: [1, 2] + remap_refs: path_sketch, sketch + remap_bodies: targets[0] +shell + remap_bodies: body +draft + remap_refs: neutral.base.base.name + remap_bodies: body +construction_plane + remap_refs: plane.base.base.name +split + remap_refs: plane.base.base.name + remap_bodies: body, tool.body +construction_axis +construction_point_planes +construction_point_along +split_face + sketches: [6] + remap_refs: plane.base.base.name, sketch + remap_bodies: body, tool.body +scale + remap_bodies: bodies[0] +offset_face + remap_bodies: body +delete_face + remap_bodies: body +bounding_solid + remap_bodies: bodies[0] +pipe + sketches: [1] + remap_refs: path_sketch + remap_bodies: targets[0] +emboss + sketches: [1] + remap_refs: sketch + remap_bodies: face.body, targets[0] +rib + sketches: [1] + remap_refs: sketch +replace_face + remap_refs: target.base.base.name + remap_bodies: body +align + remap_bodies: bodies[0] +coil + remap_bodies: targets[0] +sheet_base + sketches: [1] + remap_refs: sketch +sheet_contour + sketches: [1] + remap_refs: sketch +sheet_flange + remap_bodies: body +sheet_hem + remap_bodies: body +sheet_fold + sketches: [7] + remap_refs: sketch + remap_bodies: body +sheet_unfold + remap_bodies: body +sheet_convert + remap_bodies: body +boss + remap_bodies: body +lip + remap_bodies: body +snap_fit + remap_bodies: body +rest + remap_bodies: body +boundary_fill + remap_bodies: tools[0] +remove + remap_bodies: bodies[0] +patch + sketches: [8] + remap_refs: sketch + remap_bodies: body +stitch + remap_bodies: bodies[0] +thicken + remap_bodies: bodies[0], targets[0] +surface_extrude + sketches: [1] + remap_refs: sketch +surface_revolve + sketches: [1] + remap_refs: sketch +surface_sweep + sketches: [1, 2] + remap_refs: path_sketch, sketch +surface_loft + sketches: [4, 5] + remap_refs: sections[0].sketch, sections[1].sketch +surface_offset + remap_bodies: body +unstitch + remap_bodies: body +reverse_normal + remap_bodies: body +surface_extend + remap_bodies: body +surface_trim + remap_refs: plane.base.base.name + remap_bodies: body, tool.body +move + remap_bodies: bodies[0] +import +mesh_convert + remap_bodies: body +mesh_import diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 5b618438..978556b5 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -19,6 +19,7 @@ mod document; mod eval; pub mod expr; pub mod face_anchor; +mod feature_refs; pub use face_anchor::{FaceAnchor, FaceAttachmentStatus}; pub mod format; mod format_offsets; diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 59cf0512..cb0aa91e 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -342,41 +342,6 @@ fn input_sketches_cover_points_and_patches() { assert_eq!(pipe.input_sketches(), vec![72]); } -/// Guard for `FeatureKind::input_sketches`: a variant with a field that refers to a sketch -/// (a field named `*sketch*` holding an id, sketch points or loft sections) must not sit in the -/// arm that returns no sketches. New variants are caught by the compiler (the match has no -/// wildcard); this catches a sketch field added to an existing variant. -#[test] -fn input_sketches_lists_every_variant_with_a_sketch_field() { - let src = include_str!("document.rs"); - let body = src.split("pub enum FeatureKind {").nth(1).and_then(|s| s.split("\n}\n").next()).unwrap(); - let mut with_sketch = Vec::new(); - let mut variant = ""; - for line in body.lines() { - let t = line.trim(); - if line.starts_with(" ") && !line.starts_with(" ") && t.chars().next().is_some_and(|c| c.is_ascii_uppercase()) { - variant = t.split(|c: char| !c.is_alphanumeric()).next().unwrap_or(""); - continue; - } - let Some((name, ty)) = t.strip_prefix("pub ").unwrap_or(t).split_once(':') else { continue }; - let refers = (name.contains("sketch") && (ty.contains("u64") || ty.contains("SketchPoints"))) - || ty.contains("SketchPoints") - || ty.contains("LoftSection"); - if refers && !line.trim_start().starts_with("//") && !with_sketch.contains(&variant) { - with_sketch.push(variant); - } - } - assert!(with_sketch.len() >= 10, "the FeatureKind parse found too few sketch users: {with_sketch:?}"); - let f = src.split("pub fn input_sketches(&self)").nth(1).unwrap(); - let empty_arm = f.split("=> Vec::new()").next().and_then(|s| s.rsplit("=> ").next()).unwrap(); - for v in with_sketch { - assert!( - !empty_arm.contains(&format!("K::{v} {{")), - "FeatureKind::{v} has a sketch field but input_sketches says it uses no sketch: deleting the sketch would leave it broken" - ); - } -} - #[test] fn timeline_clones_share_meshes_even_when_cloned_before_tessellation() { let body = solidcraft_kernel::box_solid(Vec3::ZERO, Vec3::new(10., 10., 10.)).unwrap(); diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index 7a8bbfbe..ed9edad8 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -391,4 +391,25 @@ mod tests { assert_eq!(m["body_count"], 2, "{m}"); assert!((volume(&mut s) - 2.0 * one).abs() < 1e-6, "both blocks are rounded: {m}"); } + + /// A pasted coil that joins a body made by an earlier copy joins that copy, not the original. + #[test] + fn a_pasted_coil_joins_the_pasted_body() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 30, "width": 30, "height": 5, "name": "Base", "body_name": "Plate"})); + run( + &mut s, + "solid.coil", + json!({"diameter": 16, "pitch": 6, "revolutions": 3, "section_size": 2, "base": [15, 15, 5], "operation": "join", "targets": ["Plate"], "name": "Spring"}), + ); + run(&mut s, "timeline.copy", json!({"features": ["Base", "Spring"]})); + let r = run(&mut s, "timeline.paste", json!({"translate": [50, 0, 0]})); + assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{r}"); + let spring = r["pasted"][1]["name"].as_str().unwrap_or_default().to_string(); + let def = run(&mut s, "timeline.inspect", json!({"feature": spring})); + assert_eq!(def["definition"]["targets"], json!(["Plate (copy)"]), "{def}"); + let st = s.world_state(); + let v = |name: &str| solidcraft_kernel::measure(&st.body(name).unwrap().body).unwrap().volume; + assert!((v("Plate") - v("Plate (copy)")).abs() < 1e-6, "each plate carries one spring: {} vs {}", v("Plate"), v("Plate (copy)")); + } } diff --git a/crates/engine/src/cmd/surface.rs b/crates/engine/src/cmd/surface.rs index 88622ad1..4e8316ab 100644 --- a/crates/engine/src/cmd/surface.rs +++ b/crates/engine/src/cmd/surface.rs @@ -661,6 +661,25 @@ mod tests { assert_eq!(s.doc.features.len(), before, "a planned command changed the design"); } + /// A trim keeps its construction plane when the plane is renamed. + #[test] + fn renaming_a_plane_keeps_a_surface_trim_on_it() { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 20]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "surface.patch", json!({"sketch": "S", "body_name": "Sheet"})); + run(&mut s, "construct.plane.offset", json!({"base": "YZ", "offset": 25, "name": "Cut"})); + run(&mut s, "surface.trim", json!({"body": "Sheet", "plane": "Cut", "keep": [10, 10, 0], "name": "Trim"})); + let trimmed = area(&s, "Sheet"); + run(&mut s, "timeline.rename", json!({"feature": "Cut", "name": "Cutter"})); + assert!(s.model.results.iter().all(|r| r.error.is_none()), "the trim still finds its plane"); + let def = run(&mut s, "timeline.inspect", json!({"feature": "Trim"})); + assert_eq!(def["definition"]["plane"]["name"], "Cutter", "{def}"); + assert!((area(&s, "Sheet") - trimmed).abs() < 1e-6, "{} vs {trimmed}", area(&s, "Sheet")); + assert!(trimmed < 40.0 * 20.0 * 0.9, "the sheet is trimmed: {trimmed}"); + } + /// A rectangle patched, trimmed by a plane and thickened; a box's open top patched; a rim /// that is not planar patched. #[test]