From 1a456d2d1f381e47205f77fb4a7cf79aa697ab1d Mon Sep 17 00:00:00 2001 From: JIJINBEI <87472238+jijinbei@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:29:31 +0900 Subject: [PATCH] One walk over the geometry a feature holds (3/3, #876) (#1028) Moves the per-feature geometry handling (transforms, face points, profile selections, point expressions) into one geometry walk in feature_geometry.rs, with a golden record of what each feature kind moves. sheet.perforate holds no world geometry and joins the no-op arm. --- crates/doc/src/assembly.rs | 166 +------- crates/doc/src/document.rs | 75 ---- crates/doc/src/feature_geometry.rs | 381 ++++++++++++++++++ crates/doc/src/feature_walk_tests.rs | 51 ++- crates/doc/src/feature_walk_tests/fixtures.rs | 35 ++ .../feature_walk_tests/golden_geometry.txt | 161 ++++++++ crates/doc/src/lib.rs | 1 + crates/doc/src/params.rs | 26 -- crates/engine/src/cmd/clipboard.rs | 71 ++++ 9 files changed, 709 insertions(+), 258 deletions(-) create mode 100644 crates/doc/src/feature_geometry.rs create mode 100644 crates/doc/src/feature_walk_tests/golden_geometry.txt diff --git a/crates/doc/src/assembly.rs b/crates/doc/src/assembly.rs index da030dae..0d2f5a1e 100644 --- a/crates/doc/src/assembly.rs +++ b/crates/doc/src/assembly.rs @@ -11,7 +11,7 @@ use serde::{Deserialize, Serialize}; use solidcraft_geom::{Plane, Vec3}; -use crate::{AxisRef, DocError, Document, FeatureKind, PatternKind, PlaneRef, Result}; +use crate::{DocError, Document, FeatureKind, PlaneRef, Result}; /// Column-major 4×4 affine transform (`m[col][row]`). pub type Mat = [[f64; 4]; 4]; @@ -204,160 +204,16 @@ pub fn map_point_expr(m: &Mat, p: &mut crate::PointExpr) { impl FeatureKind { /// Map the feature's world geometry (points, directions, explicit planes) by `m`. pub fn transform_geometry(&mut self, m: &Mat) { - let pt = |p: &mut Vec3| *p = apply_point(m, *p); - let dir = |v: &mut Vec3| *v = apply_vector(m, *v); - match self { - FeatureKind::Sketch { plane, .. } | FeatureKind::ConstructionPlane { plane } | FeatureKind::Mirror { plane, .. } => { - map_plane_ref(m, plane) - } - FeatureKind::ConstructionAxis { def } => def.refs_mut().into_iter().for_each(|r| map_geo_ref(m, r)), - FeatureKind::ConstructionPoint { def } => def.refs_mut().into_iter().for_each(|r| map_geo_ref(m, r)), - FeatureKind::Split { plane, tool, .. } => { - map_plane_ref(m, plane); - if let Some(t) = tool { - pt(&mut t.point); - } - } - FeatureKind::SplitFace { faces, plane, tool, .. } => { - faces.iter_mut().for_each(pt); - map_plane_ref(m, plane); - if let Some(t) = tool { - pt(&mut t.point); - } - } - FeatureKind::Revolve { axis: AxisRef::Line { origin, dir: d }, .. } - | FeatureKind::SurfaceRevolve { axis: AxisRef::Line { origin, dir: d }, .. } => { - pt(origin); - dir(d); - } - FeatureKind::Fillet { edges, style, .. } => { - edges.iter_mut().for_each(pt); - if let crate::FilletStyle::Variable { start, .. } | crate::FilletStyle::Spline { start, .. } = style { - pt(start); - } - } - FeatureKind::Chamfer { edges, .. } => edges.iter_mut().for_each(pt), - FeatureKind::Shell { faces, .. } - | FeatureKind::OffsetFace { faces, .. } - | FeatureKind::DeleteFace { faces, .. } - | FeatureKind::SurfaceOffset { faces, .. } => faces.iter_mut().for_each(pt), - FeatureKind::Draft { faces, neutral, pull, .. } => { - faces.iter_mut().for_each(pt); - map_plane_ref(m, neutral); - dir(pull); - } - FeatureKind::Box { corner, .. } => map_point_expr(m, corner), - FeatureKind::Cylinder { base, axis, .. } => { - map_point_expr(m, base); - dir(axis); - } - FeatureKind::Sphere { center, .. } | FeatureKind::Torus { center, .. } => map_point_expr(m, center), - FeatureKind::Pattern { pattern: PatternKind::Rectangular { dir1, dir2, .. }, .. } => { - dir(dir1); - if let Some(d) = dir2 { - dir(d); - } - } - FeatureKind::Pattern { pattern: PatternKind::Circular { origin, axis, .. }, .. } => { - pt(origin); - dir(axis); - } - // Follows its sketch. - FeatureKind::Pattern { pattern: PatternKind::Path { .. }, .. } => {} - FeatureKind::Hole { position, direction, .. } => { - pt(position); - dir(direction); - } - FeatureKind::Thread { face, .. } => pt(face), - FeatureKind::SheetFlange { edges, .. } => edges.iter_mut().for_each(|e| pt(&mut e.point)), - FeatureKind::SheetHem { edges, .. } => edges.iter_mut().for_each(pt), - FeatureKind::SheetConvert { face, bends, .. } => { - pt(face); - bends.iter_mut().for_each(pt); - } - FeatureKind::SheetFold { a, b, fixed, .. } => { - pt(a); - pt(b); - if let Some(f) = fixed { - pt(f); - } - } - FeatureKind::Boss { position, direction, .. } => { - pt(position); - dir(direction); - } - FeatureKind::Lip { face, .. } => pt(face), - FeatureKind::BoundaryFill { cells, .. } => cells.iter_mut().for_each(pt), - FeatureKind::Rest { position, direction, along, .. } => { - pt(position); - dir(direction); - if let Some(a) = along { - dir(a); - } - } - FeatureKind::SnapFit { position, direction, hook, .. } => { - pt(position); - dir(direction); - dir(hook); - } - FeatureKind::SheetBase { .. } - | FeatureKind::SheetContour { .. } - | FeatureKind::SheetUnfold { .. } - | FeatureKind::SheetPerforate { .. } => {} - FeatureKind::Coil { base, axis, .. } => { - map_point_expr(m, base); - dir(axis); - } - FeatureKind::ReplaceFace { faces, target, .. } => { - faces.iter_mut().for_each(pt); - map_plane_ref(m, target); - } - FeatureKind::Align { from, to, from_normal, to_normal, .. } => { - pt(from); - pt(to); - if let Some(n) = from_normal { - dir(n); - } - if let Some(n) = to_normal { - dir(n); - } - } - FeatureKind::Scale { origin, .. } => pt(origin), - FeatureKind::Move { rotate_axis, .. } => { - if let Some(a) = rotate_axis { - dir(a); - } - } - FeatureKind::Revolve { .. } - | FeatureKind::Extrude { .. } - | FeatureKind::SurfaceExtrude { .. } - | FeatureKind::SurfaceRevolve { .. } - | FeatureKind::SurfaceSweep { .. } - | FeatureKind::SurfaceLoft { .. } - | FeatureKind::Combine { .. } - | FeatureKind::Loft { .. } - | FeatureKind::Sweep { .. } - | FeatureKind::BoundingSolid { .. } - | FeatureKind::Pipe { .. } - | FeatureKind::Emboss { .. } - | FeatureKind::Rib { .. } - | FeatureKind::Remove { .. } - | FeatureKind::Stitch { .. } - | FeatureKind::Thicken { .. } - | FeatureKind::Import { .. } - | FeatureKind::MeshImport { .. } - | FeatureKind::MeshConvert { .. } - | FeatureKind::BodyTransfer { .. } => {} - FeatureKind::Patch { edges, .. } | FeatureKind::SurfaceExtend { edges, .. } => edges.iter_mut().for_each(pt), - FeatureKind::Unstitch { faces, .. } | FeatureKind::ReverseNormal { faces, .. } => faces.iter_mut().for_each(pt), - FeatureKind::SurfaceTrim { plane, tool, keep, .. } => { - map_plane_ref(m, plane); - if let Some(t) = tool { - pt(&mut t.point); - } - pt(keep); - } - } + use crate::feature_geometry::Geo; + self.walk_geometry_mut(&mut |g| match g { + Geo::Pick(p) | Geo::Pos(p) => *p = apply_point(m, *p), + Geo::Dir(v) => *v = apply_vector(m, *v), + Geo::Plane(p) => map_plane_ref(m, p), + Geo::Construct(r) => map_geo_ref(m, r), + Geo::Placement(e) => map_point_expr(m, e), + // An Emboss cylinder is picked in its component's frame (`EmbossFace`). + Geo::Pinned(_) | Geo::Profiles(..) => {} + }); } } diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 4841021c..be8a1181 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -1523,81 +1523,6 @@ impl FeatureKind { FeatureKind::MeshConvert { .. } => "ConvertMesh", } } - /// Every expression the feature uses. - /// Points on faces the feature refers to (shell, draft, offset and replace faces, a hole's - /// placement), for persistent face names. - /// The feature's profile selections, each with its sketch. - pub fn profile_sels_mut(&mut self) -> Vec<(u64, &mut ProfileSel)> { - match self { - // A thin extrude follows curves; its profiles are not used. - FeatureKind::Extrude { thin: Some(_), .. } => Vec::new(), - FeatureKind::Extrude { sketch, profiles, .. } - | FeatureKind::Revolve { sketch, profiles, .. } - | FeatureKind::Sweep { sketch, profiles, .. } - | FeatureKind::Emboss { sketch, profiles, .. } - | FeatureKind::SheetBase { sketch, profiles, .. } => vec![(*sketch, profiles)], - FeatureKind::Patch { sketch: Some(sk), profiles, .. } - | FeatureKind::SurfaceExtrude { sketch: sk, profiles: Some(profiles), .. } - | FeatureKind::SurfaceRevolve { sketch: sk, profiles: Some(profiles), .. } - | FeatureKind::SurfaceSweep { sketch: sk, profiles: Some(profiles), .. } => { - vec![(*sk, profiles)] - } - FeatureKind::Loft { sections, .. } => sections.iter_mut().filter(|s| s.point.is_none()).map(|s| (s.sketch, &mut s.profiles)).collect(), - FeatureKind::SurfaceLoft { sections } => sections.iter_mut().filter_map(|s| Some((s.sketch, s.profiles.as_mut()?))).collect(), - _ => Vec::new(), - } - } - - /// Profile selections and their source sketches without cloning feature data. - pub fn profile_sels(&self) -> Vec<(u64, &ProfileSel)> { - match self { - // A thin extrude follows curves; its profiles are not used. - FeatureKind::Extrude { thin: Some(_), .. } => Vec::new(), - FeatureKind::Extrude { sketch, profiles, .. } - | FeatureKind::Revolve { sketch, profiles, .. } - | FeatureKind::Sweep { sketch, profiles, .. } - | FeatureKind::Emboss { sketch, profiles, .. } - | FeatureKind::SheetBase { sketch, profiles, .. } => vec![(*sketch, profiles)], - FeatureKind::Patch { sketch: Some(sketch), profiles, .. } - | FeatureKind::SurfaceExtrude { sketch, profiles: Some(profiles), .. } - | FeatureKind::SurfaceRevolve { sketch, profiles: Some(profiles), .. } - | FeatureKind::SurfaceSweep { sketch, profiles: Some(profiles), .. } => { - vec![(*sketch, profiles)] - } - FeatureKind::Loft { sections, .. } => { - sections.iter().filter(|section| section.point.is_none()).map(|section| (section.sketch, §ion.profiles)).collect() - } - FeatureKind::SurfaceLoft { sections } => sections.iter().filter_map(|s| Some((s.sketch, s.profiles.as_ref()?))).collect(), - _ => Vec::new(), - } - } - - pub fn face_points_mut(&mut self) -> Vec<&mut Vec3> { - match self { - FeatureKind::Shell { faces, .. } - | FeatureKind::Draft { faces, .. } - | FeatureKind::OffsetFace { faces, .. } - | FeatureKind::DeleteFace { faces, .. } - | FeatureKind::SurfaceOffset { faces, .. } - | FeatureKind::SplitFace { faces, .. } - | FeatureKind::ReplaceFace { faces, .. } => faces.iter_mut().collect(), - FeatureKind::Hole { position, points: None, to, .. } => std::iter::once(position).chain(to.iter_mut()).collect(), - FeatureKind::Hole { to: Some(t), .. } => vec![t], - FeatureKind::Extrude { extent, .. } => extent.to.iter_mut().chain(extent.from.iter_mut()).collect(), - FeatureKind::Revolve { to: Some(p), .. } => vec![p], - FeatureKind::Thread { face, .. } => vec![face], - FeatureKind::Emboss { face: Some(face), .. } => vec![&mut face.at], - FeatureKind::Boss { position, .. } | FeatureKind::SnapFit { position, .. } | FeatureKind::Rest { position, .. } => vec![position], - // With bend edges the fixed face is found by name (a plate keeps its point). - FeatureKind::SheetConvert { face, bends, .. } if !bends.is_empty() => vec![face], - _ => Vec::new(), - } - } - - pub fn face_points(&self) -> Vec { - let mut k = self.clone(); - k.face_points_mut().into_iter().map(|p| *p).collect() - } } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/doc/src/feature_geometry.rs b/crates/doc/src/feature_geometry.rs new file mode 100644 index 00000000..1a21adcf --- /dev/null +++ b/crates/doc/src/feature_geometry.rs @@ -0,0 +1,381 @@ +//! The geometry a feature holds, each piece with its role: the points that pick faces (moved +//! with the feature, and the source of its persistent face names), other positions, directions, +//! planes, construction references, a primitive's placement point and its profile selections. +//! One walk, written once for `&` and `&mut`, behind `transform_geometry`, `face_points`, +//! `profile_sels` and `point_expr`. Every variant is listed, so a new one has to say what each of +//! its points is. + +use solidcraft_geom::Vec3; + +use crate::construct::GeoRef; +use crate::{AxisRef, FeatureKind, PatternKind, PlaneRef, PointExpr, ProfileSel}; + +/// A piece of a feature's geometry: `V` is `&Vec3` or `&mut Vec3`, and so on for the others. +pub(crate) enum Geo { + /// A point that picks a face: it moves with the feature and names the face it picks. + Pick(V), + /// A point that picks a face but stays where it is when the feature is moved (an Emboss + /// cylinder, chosen in its component's frame). + Pinned(V), + /// Any other position (an edge pick, an axis origin, a fold line): it moves with the feature. + Pos(V), + /// A direction: it turns with the feature. + Dir(V), + Plane(P), + /// A construction axis's or point's reference to the model. + Construct(G), + /// A primitive's placement point (length expressions). + Placement(E), + /// A profile selection, with its sketch. + Profiles(u64, S), +} + +pub(crate) type GeoItem<'a> = Geo<&'a Vec3, &'a PlaneRef, &'a GeoRef, &'a PointExpr, &'a ProfileSel>; +pub(crate) type GeoItemMut<'a> = Geo<&'a mut Vec3, &'a mut PlaneRef, &'a mut GeoRef, &'a mut PointExpr, &'a mut ProfileSel>; + +/// Every piece of a feature's geometry, written once for `&` and `&mut`: `$item` is `GeoItem` or +/// `GeoItemMut`, `$refs` the matching `refs` / `refs_mut`, `$m` is `mut` or nothing. Face picks +/// come in the order the feature's face names follow. +macro_rules! geometry_walk { + ($name:ident, $item:ident, $refs:ident $(, $m:tt)?) => { + pub(crate) fn $name<'a>(&'a $($m)? self, f: &mut impl FnMut($item<'a>)) { + use Geo::{Construct, Dir, Pick, Pinned, Placement, Plane, Pos, Profiles}; + match self { + FeatureKind::Sketch { plane, .. } | FeatureKind::ConstructionPlane { plane } | FeatureKind::Mirror { plane, .. } => f(Plane(plane)), + FeatureKind::ConstructionAxis { def } => { + for r in def.$refs() { + f(Construct(r)); + } + } + FeatureKind::ConstructionPoint { def } => { + for r in def.$refs() { + f(Construct(r)); + } + } + FeatureKind::Extrude { sketch, profiles, extent, thin, .. } => { + // A thin extrude follows curves; its profiles are not used. + if thin.is_none() { + f(Profiles(*sketch, profiles)); + } + if let Some(p) = & $($m)? extent.to { + f(Pick(p)); + } + if let Some(p) = & $($m)? extent.from { + f(Pick(p)); + } + } + // Surface Extrude refuses `to` and `from` for now; they would pick faces as for + // Extrude. + FeatureKind::SurfaceExtrude { sketch, profiles, extent, .. } => { + if let Some(s) = profiles { + f(Profiles(*sketch, s)); + } + if let Some(p) = & $($m)? extent.to { + f(Pick(p)); + } + if let Some(p) = & $($m)? extent.from { + f(Pick(p)); + } + } + FeatureKind::Revolve { sketch, profiles, axis, to, .. } => { + f(Profiles(*sketch, profiles)); + if let AxisRef::Line { origin, dir } = axis { + f(Pos(origin)); + f(Dir(dir)); + } + if let Some(p) = to { + f(Pick(p)); + } + } + FeatureKind::SurfaceRevolve { sketch, profiles, axis, .. } => { + if let Some(s) = profiles { + f(Profiles(*sketch, s)); + } + if let AxisRef::Line { origin, dir } = axis { + f(Pos(origin)); + f(Dir(dir)); + } + } + FeatureKind::Sweep { sketch, profiles, .. } | FeatureKind::SheetBase { sketch, profiles, .. } => f(Profiles(*sketch, profiles)), + FeatureKind::SurfaceSweep { sketch, profiles, .. } => { + if let Some(s) = profiles { + f(Profiles(*sketch, s)); + } + } + FeatureKind::Emboss { sketch, profiles, face, .. } => { + f(Profiles(*sketch, profiles)); + if let Some(face) = face { + f(Pinned(& $($m)? face.at)); + } + } + FeatureKind::Loft { sections, .. } => { + // A section that is a point has no profile. + for s in sections { + if s.point.is_none() { + f(Profiles(s.sketch, & $($m)? s.profiles)); + } + } + } + FeatureKind::SurfaceLoft { sections } => { + for s in sections { + if let Some(p) = & $($m)? s.profiles { + f(Profiles(s.sketch, p)); + } + } + } + FeatureKind::Patch { sketch, profiles, edges, .. } => { + if let Some(sketch) = sketch { + f(Profiles(*sketch, profiles)); + } + for e in edges { + f(Pos(e)); + } + } + FeatureKind::Fillet { edges, style, .. } => { + for e in edges { + f(Pos(e)); + } + if let crate::FilletStyle::Variable { start, .. } | crate::FilletStyle::Spline { start, .. } = style { + f(Pos(start)); + } + } + FeatureKind::Chamfer { edges, .. } | FeatureKind::SheetHem { edges, .. } | FeatureKind::SurfaceExtend { edges, .. } => { + for e in edges { + f(Pos(e)); + } + } + FeatureKind::Shell { faces, .. } + | FeatureKind::OffsetFace { faces, .. } + | FeatureKind::DeleteFace { faces, .. } + | FeatureKind::SurfaceOffset { faces, .. } => { + for p in faces { + f(Pick(p)); + } + } + FeatureKind::Unstitch { faces, .. } | FeatureKind::ReverseNormal { faces, .. } => { + for p in faces { + f(Pos(p)); + } + } + FeatureKind::BoundaryFill { cells, .. } => { + for p in cells { + f(Pos(p)); + } + } + FeatureKind::Draft { faces, neutral, pull, .. } => { + for p in faces { + f(Pick(p)); + } + f(Plane(neutral)); + f(Dir(pull)); + } + FeatureKind::ReplaceFace { faces, target, .. } => { + for p in faces { + f(Pick(p)); + } + f(Plane(target)); + } + FeatureKind::SplitFace { faces, plane, tool, .. } => { + for p in faces { + f(Pick(p)); + } + f(Plane(plane)); + if let Some(t) = tool { + f(Pos(& $($m)? t.point)); + } + } + FeatureKind::Split { plane, tool, .. } => { + f(Plane(plane)); + if let Some(t) = tool { + f(Pos(& $($m)? t.point)); + } + } + FeatureKind::SurfaceTrim { plane, tool, keep, .. } => { + f(Plane(plane)); + if let Some(t) = tool { + f(Pos(& $($m)? t.point)); + } + f(Pos(keep)); + } + FeatureKind::Box { corner: p, .. } | FeatureKind::Sphere { center: p, .. } | FeatureKind::Torus { center: p, .. } => { + f(Placement(p)) + } + FeatureKind::Cylinder { base, axis, .. } | FeatureKind::Coil { base, axis, .. } => { + f(Placement(base)); + f(Dir(axis)); + } + FeatureKind::Pattern { pattern, .. } => match pattern { + PatternKind::Rectangular { dir1, dir2, .. } => { + f(Dir(dir1)); + if let Some(d) = dir2 { + f(Dir(d)); + } + } + PatternKind::Circular { origin, axis, .. } => { + f(Pos(origin)); + f(Dir(axis)); + } + // Follows its path sketch. + PatternKind::Path { .. } => {} + }, + FeatureKind::Hole { position, direction, points, to, .. } => { + // Placed at sketch points, the hole names no face by its position. + if points.is_none() { + f(Pick(position)); + } else { + f(Pos(position)); + } + f(Dir(direction)); + if let Some(p) = to { + f(Pick(p)); + } + } + FeatureKind::Thread { face, .. } => f(Pick(face)), + FeatureKind::SheetFlange { edges, .. } => { + for e in edges { + f(Pos(& $($m)? e.point)); + } + } + FeatureKind::SheetFold { a, b, fixed, .. } => { + f(Pos(a)); + f(Pos(b)); + if let Some(p) = fixed { + f(Pos(p)); + } + } + FeatureKind::SheetConvert { face, bends, .. } => { + // With bend edges the fixed face is found by name (a plate keeps its point). + if bends.is_empty() { + f(Pos(face)); + } else { + f(Pick(face)); + } + for p in bends { + f(Pos(p)); + } + } + FeatureKind::Boss { position, direction, .. } => { + f(Pick(position)); + f(Dir(direction)); + } + FeatureKind::SnapFit { position, direction, hook, .. } => { + f(Pick(position)); + f(Dir(direction)); + f(Dir(hook)); + } + FeatureKind::Rest { position, direction, along, .. } => { + f(Pick(position)); + f(Dir(direction)); + if let Some(a) = along { + f(Dir(a)); + } + } + FeatureKind::Lip { face, .. } => f(Pos(face)), + FeatureKind::Align { from, to, from_normal, to_normal, .. } => { + f(Pos(from)); + f(Pos(to)); + if let Some(n) = from_normal { + f(Dir(n)); + } + if let Some(n) = to_normal { + f(Dir(n)); + } + } + FeatureKind::Scale { origin, .. } => f(Pos(origin)), + FeatureKind::Move { rotate_axis, .. } => { + if let Some(a) = rotate_axis { + f(Dir(a)); + } + } + FeatureKind::SheetContour { .. } + | FeatureKind::SheetPerforate { .. } + | FeatureKind::SheetUnfold { .. } + | FeatureKind::Combine { .. } + | FeatureKind::BoundingSolid { .. } + | FeatureKind::Pipe { .. } + | FeatureKind::Rib { .. } + | FeatureKind::Remove { .. } + | FeatureKind::Stitch { .. } + | FeatureKind::Thicken { .. } + | FeatureKind::Import { .. } + | FeatureKind::MeshImport { .. } + | FeatureKind::MeshConvert { .. } + | FeatureKind::BodyTransfer { .. } => {} + } + } + }; +} + +impl FeatureKind { + geometry_walk!(walk_geometry, GeoItem, refs); + geometry_walk!(walk_geometry_mut, GeoItemMut, refs_mut, mut); + + /// The points that pick faces (shell, draft, offset and replace faces, a hole's placement and + /// `to`, an extrude's `to` and `from`…), in the order of the feature's persistent face names. + pub fn face_points_mut(&mut self) -> Vec<&mut Vec3> { + let mut v = Vec::new(); + self.walk_geometry_mut(&mut |g| { + if let Geo::Pick(p) | Geo::Pinned(p) = g { + v.push(p); + } + }); + v + } + + /// Read-only view of [`FeatureKind::face_points_mut`]. + pub fn face_points(&self) -> Vec { + let mut v = Vec::new(); + self.walk_geometry(&mut |g| { + if let Geo::Pick(p) | Geo::Pinned(p) = g { + v.push(*p); + } + }); + v + } + + /// The feature's profile selections, each with its sketch. + pub fn profile_sels_mut(&mut self) -> Vec<(u64, &mut ProfileSel)> { + let mut v = Vec::new(); + self.walk_geometry_mut(&mut |g| { + if let Geo::Profiles(sketch, sel) = g { + v.push((sketch, sel)); + } + }); + v + } + + /// Read-only view of [`FeatureKind::profile_sels_mut`]. + pub fn profile_sels(&self) -> Vec<(u64, &ProfileSel)> { + let mut v = Vec::new(); + self.walk_geometry(&mut |g| { + if let Geo::Profiles(sketch, sel) = g { + v.push((sketch, sel)); + } + }); + v + } + + /// A primitive's placement point (length expressions). Its coordinates are not inputs: + /// they get no parameter names and keep bare numbers in mm whatever the design's units, as + /// every other position does; renames and users still find them. + pub fn point_expr_mut(&mut self) -> Option<&mut PointExpr> { + let mut found = None; + self.walk_geometry_mut(&mut |g| { + if let Geo::Placement(p) = g { + found = Some(p); + } + }); + found + } + + /// Read-only view of [`FeatureKind::point_expr_mut`]. + pub fn point_expr(&self) -> Option<&PointExpr> { + let mut found = None; + self.walk_geometry(&mut |g| { + if let Geo::Placement(p) = g { + found = Some(p); + } + }); + found + } +} diff --git a/crates/doc/src/feature_walk_tests.rs b/crates/doc/src/feature_walk_tests.rs index 95c64a30..907edbb6 100644 --- a/crates/doc/src/feature_walk_tests.rs +++ b/crates/doc/src/feature_walk_tests.rs @@ -4,7 +4,9 @@ //! 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. +//! `remap_bodies` rewrite when everything is copied under new ids and names; +//! - geometry: the fields `transform_geometry` moves, the face points, the profile selections and +//! the placement point. //! //! The records live in `feature_walk_tests/*.txt`; to record an intended change, run the tests //! with `FEATURE_WALK_BLESS=1`. @@ -13,7 +15,7 @@ mod fixtures; use std::collections::BTreeMap; -use fixtures::{changed, fixtures, with_refs}; +use fixtures::{changed, fixtures, with_points, with_refs}; use crate::FeatureKind; @@ -75,6 +77,42 @@ fn ref_record() -> String { /// 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). +/// Per fixture (with every reference and position set): the fields `transform_geometry` changes +/// under a turn and a shift (every point, direction and plane it maps), the face points that name +/// faces, the profile selections with their sketches, and a primitive's placement point. +fn geometry_record() -> String { + // x → y → z → x, then (1, 2, 3): every point and every direction of the fixtures changes. + let m = [[0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [1.0, 0.0, 0.0, 0.0], [1.0, 2.0, 3.0, 1.0]]; + let mut out = String::new(); + for (name, v) in fixtures() { + let kind: FeatureKind = serde_json::from_value(with_points(name, with_refs(name, v))).unwrap_or_else(|e| panic!("{name}: {e}")); + let mut moved = kind.clone(); + moved.transform_geometry(&m); + let mut paths = Vec::new(); + changed(&serde_json::to_value(&kind).unwrap(), &serde_json::to_value(&moved).unwrap(), "", &mut paths); + out += &format!("{name}\n"); + if !paths.is_empty() { + out += &format!(" moves: {}\n", paths.join(", ")); + } + let faces: Vec = kind.face_points().iter().map(|p| format!("[{}, {}, {}]", p.x, p.y, p.z)).collect(); + if !faces.is_empty() { + out += &format!(" face points: {}\n", faces.join(", ")); + } + let profiles: Vec = kind + .profile_sels() + .iter() + .map(|(sketch, sel)| format!("{sketch} {}", serde_json::to_value(sel).unwrap()["type"].as_str().unwrap_or("?"))) + .collect(); + if !profiles.is_empty() { + out += &format!(" profiles: {}\n", profiles.join(", ")); + } + if let Some(p) = kind.point_expr() { + out += &format!(" placement: {}\n", p.join(", ")); + } + } + out +} + /// 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). @@ -93,6 +131,10 @@ 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 `transform_geometry`, `face_points`, `profile_sels` and +/// `point_expr` the walk replaced. +const GOLDEN_GEOMETRY: &str = include_str!("feature_walk_tests/golden_geometry.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"); @@ -125,6 +167,11 @@ fn input_sketches_lists_every_variant_with_a_sketch_field() { } } +#[test] +fn geometry_matches_the_golden_record() { + check_golden("golden_geometry.txt", GOLDEN_GEOMETRY, &geometry_record()); +} + #[test] fn references_match_the_golden_record() { check_golden("golden_refs.txt", GOLDEN_REFS, &ref_record()); diff --git a/crates/doc/src/feature_walk_tests/fixtures.rs b/crates/doc/src/feature_walk_tests/fixtures.rs index b296d1f5..750f007e 100644 --- a/crates/doc/src/feature_walk_tests/fixtures.rs +++ b/crates/doc/src/feature_walk_tests/fixtures.rs @@ -191,6 +191,41 @@ pub(super) fn with_refs(name: &str, mut v: Value) -> Value { v } +/// The positions and directions a fixture leaves out (they don't change its expressions or +/// references), set here so every point, direction and axis the geometry walks read is present: +/// `to` / `from` picks, line axes, normals and origins. `hole_simple` places its hole at +/// `position` (no sketch points), the other holes at sketch points. +pub(super) fn with_points(name: &str, mut v: Value) -> Value { + let p = |i: f64| json!([i, i + 0.5, i + 0.25]); + let line = json!({"type": "line", "origin": p(9.0), "dir": [0.0, 0.6, 0.8]}); + let extra = match name { + "extrude" | "surface_extrude" => { + if let Some(e) = v.get_mut("extent").and_then(Value::as_object_mut) { + e.insert("to".into(), p(1.0)); + e.insert("from".into(), p(2.0)); + } + json!({}) + } + "revolve" => json!({"to": p(3.0), "axis": line}), + "surface_revolve" => json!({"axis": line}), + "hole_simple" => json!({"points": null, "to": p(4.0)}), + "hole_drilled" | "hole_counterbore" | "hole_countersink" => json!({"to": p(4.0)}), + "emboss" => json!({"face": {"body": "B", "at": p(5.0)}}), + "sheet_convert" => json!({"bends": [p(6.0)]}), + "sheet_fold" => json!({"fixed": p(7.0)}), + "rest" => json!({"along": [0.6, 0.8, 0.0]}), + "align" => json!({"from_normal": [0.0, 0.0, 1.0], "to_normal": [1.0, 0.0, 0.0]}), + "scale" => json!({"origin": p(8.0)}), + "move" => json!({"rotate_axis": [0.0, 1.0, 0.0]}), + "unstitch" | "reverse_normal" => json!({"faces": [p(10.0)]}), + _ => 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) { diff --git a/crates/doc/src/feature_walk_tests/golden_geometry.txt b/crates/doc/src/feature_walk_tests/golden_geometry.txt new file mode 100644 index 00000000..b5c31824 --- /dev/null +++ b/crates/doc/src/feature_walk_tests/golden_geometry.txt @@ -0,0 +1,161 @@ +body_transfer +sketch + moves: plane.axis_dir[0], plane.axis_dir[1], plane.axis_origin[0], plane.axis_origin[1], plane.axis_origin[2] +sketch_along_path + moves: plane.path.at[0], plane.path.at[1], plane.path.at[2] +extrude + moves: extent.from[0], extent.from[1], extent.from[2], extent.to[0], extent.to[1], extent.to[2] + face points: [1, 1.5, 1.25], [2, 2.5, 2.25] + profiles: 1 all +extrude_thin +revolve + moves: axis.dir[0], axis.dir[1], axis.dir[2], axis.origin[0], axis.origin[1], axis.origin[2], to[0], to[1], to[2] + face points: [3, 3.5, 3.25] + profiles: 1 all +fillet_constant + moves: edges[0][0], edges[0][1], edges[0][2] +fillet_chord + moves: edges[0][0], edges[0][1], edges[0][2] +fillet_curvature + moves: edges[0][0], edges[0][1], edges[0][2] +fillet_variable + moves: edges[0][0], edges[0][1], edges[0][2], style.start[0], style.start[1], style.start[2] +fillet_spline + moves: edges[0][0], edges[0][1], edges[0][2], style.start[0], style.start[1], style.start[2] +chamfer + moves: edges[0][0], edges[0][1], edges[0][2] +box + moves: corner[0], corner[1], corner[2] + placement: box.x, box.y, box.z +cylinder + moves: axis[0], axis[2], base[0], base[1], base[2] + placement: cyl.x, cyl.y, cyl.z +sphere + moves: center[0], center[1], center[2] + placement: sph.x, sph.y, sph.z +torus + moves: center[0], center[1], center[2] + placement: tor.x, tor.y, tor.z +combine +pattern_rectangular + moves: pattern.dir1[0], pattern.dir1[1], pattern.dir2[1], pattern.dir2[2] +pattern_circular + moves: pattern.axis[0], pattern.axis[2], pattern.origin[0], pattern.origin[1], pattern.origin[2] +pattern_path +mirror + moves: plane.axis_dir[0], plane.axis_dir[1], plane.axis_origin[0], plane.axis_origin[1], plane.axis_origin[2] +hole_simple + moves: direction[0], direction[2], position[0], position[1], position[2], to[0], to[1], to[2] + face points: [0, 0, 0], [4, 4.5, 4.25] +hole_drilled + moves: direction[0], direction[2], position[0], position[1], position[2], to[0], to[1], to[2] + face points: [4, 4.5, 4.25] +hole_counterbore + moves: direction[0], direction[2], position[0], position[1], position[2], to[0], to[1], to[2] + face points: [4, 4.5, 4.25] +hole_countersink + moves: direction[0], direction[2], position[0], position[1], position[2], to[0], to[1], to[2] + face points: [4, 4.5, 4.25] +thread + moves: face[0], face[1], face[2] + face points: [0, 0, 0] +loft + profiles: 4 all, 5 all +sweep + profiles: 1 all +shell + moves: faces[0][0], faces[0][1], faces[0][2] + face points: [0, 0, 0] +draft + moves: faces[0][0], faces[0][1], faces[0][2], neutral.axis_dir[0], neutral.axis_dir[1], neutral.axis_origin[0], neutral.axis_origin[1], neutral.axis_origin[2], pull[0], pull[2] + face points: [0, 0, 0] +construction_plane + moves: plane.axis_dir[0], plane.axis_dir[1], plane.axis_origin[0], plane.axis_origin[1], plane.axis_origin[2] +split + moves: plane.axis_dir[0], plane.axis_dir[1], plane.axis_origin[0], plane.axis_origin[1], plane.axis_origin[2], tool.point[0], tool.point[1], tool.point[2] +construction_axis + moves: def.a.plane.axis_dir[0], def.a.plane.axis_dir[1], def.a.plane.axis_origin[0], def.a.plane.axis_origin[1], def.a.plane.axis_origin[2], def.b.plane.axis_dir[0], def.b.plane.axis_dir[1], def.b.plane.axis_origin[0], def.b.plane.axis_origin[1], def.b.plane.axis_origin[2] +construction_point_planes + moves: def.a.plane.axis_dir[0], def.a.plane.axis_dir[1], def.a.plane.axis_origin[0], def.a.plane.axis_origin[1], def.a.plane.axis_origin[2], def.b.plane.axis_dir[0], def.b.plane.axis_dir[1], def.b.plane.axis_origin[0], def.b.plane.axis_origin[1], def.b.plane.axis_origin[2], def.c.plane.axis_dir[0], def.c.plane.axis_dir[1], def.c.plane.axis_origin[0], def.c.plane.axis_origin[1], def.c.plane.axis_origin[2] +construction_point_along + moves: def.path.at[0], def.path.at[1], def.path.at[2] +split_face + moves: faces[0][0], faces[0][1], faces[0][2], plane.axis_dir[0], plane.axis_dir[1], plane.axis_origin[0], plane.axis_origin[1], plane.axis_origin[2], tool.point[0], tool.point[1], tool.point[2] + face points: [0, 0, 0] +scale + moves: origin[0], origin[1], origin[2] +offset_face + moves: faces[0][0], faces[0][1], faces[0][2] + face points: [0, 0, 0] +delete_face + moves: faces[0][0], faces[0][1], faces[0][2] + face points: [0, 0, 0] +bounding_solid +pipe +emboss + face points: [5, 5.5, 5.25] + profiles: 1 all +rib +replace_face + moves: faces[0][0], faces[0][1], faces[0][2], target.axis_dir[0], target.axis_dir[1], target.axis_origin[0], target.axis_origin[1], target.axis_origin[2] + face points: [0, 0, 0] +align + moves: from[0], from[1], from[2], from_normal[0], from_normal[2], to[0], to[1], to[2], to_normal[0], to_normal[1] +coil + moves: axis[0], axis[2], base[0], base[1], base[2] + placement: coil.x, coil.y, coil.z +sheet_base + profiles: 1 all +sheet_contour +sheet_flange + moves: edges[0].point[0], edges[0].point[1], edges[0].point[2], edges[1].point[0], edges[1].point[1], edges[1].point[2] +sheet_hem + moves: edges[0][0], edges[0][1], edges[0][2] +sheet_perforate +sheet_fold + moves: a[0], a[1], a[2], b[1], b[2], fixed[0], fixed[1], fixed[2] +sheet_unfold +sheet_convert + moves: bends[0][0], bends[0][1], bends[0][2], face[0], face[1], face[2] + face points: [0, 0, 0] +boss + moves: direction[0], direction[2], position[0], position[1], position[2] + face points: [0, 0, 0] +lip + moves: face[0], face[1], face[2] +snap_fit + moves: direction[0], direction[2], hook[0], hook[1], position[0], position[1], position[2] + face points: [0, 0, 0] +rest + moves: along[0], along[1], along[2], direction[0], direction[2], position[0], position[1], position[2] + face points: [0, 0, 0] +boundary_fill + moves: cells[0][0], cells[0][1], cells[0][2] +remove +patch + profiles: 8 all +stitch +thicken +surface_extrude + moves: extent.from[0], extent.from[1], extent.from[2], extent.to[0], extent.to[1], extent.to[2] + face points: [1, 1.5, 1.25], [2, 2.5, 2.25] +surface_revolve + moves: axis.dir[0], axis.dir[1], axis.dir[2], axis.origin[0], axis.origin[1], axis.origin[2] +surface_sweep +surface_loft +surface_offset + moves: faces[0][0], faces[0][1], faces[0][2] + face points: [0, 0, 0] +unstitch + moves: faces[0][0], faces[0][1], faces[0][2] +reverse_normal + moves: faces[0][0], faces[0][1], faces[0][2] +surface_extend + moves: edges[0][0], edges[0][1], edges[0][2] +surface_trim + moves: keep[0], keep[1], keep[2], plane.axis_dir[0], plane.axis_dir[1], plane.axis_origin[0], plane.axis_origin[1], plane.axis_origin[2], tool.point[0], tool.point[1], tool.point[2] +move + moves: rotate_axis[1], rotate_axis[2] +import +mesh_convert +mesh_import diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 978556b5..5524607e 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_geometry; mod feature_refs; pub use face_anchor::{FaceAnchor, FaceAttachmentStatus}; pub mod format; diff --git a/crates/doc/src/params.rs b/crates/doc/src/params.rs index 3bb08ffc..90756f31 100644 --- a/crates/doc/src/params.rs +++ b/crates/doc/src/params.rs @@ -414,32 +414,6 @@ impl FeatureKind { self.walk_exprs(&mut |_, e, _, _| v.push(e.as_str())); v } - - /// A primitive's placement point (length expressions). Its coordinates are not inputs: - /// they get no parameter names and keep bare numbers in mm whatever the design's units, as - /// every other position does; renames and users still find them. - pub fn point_expr_mut(&mut self) -> Option<&mut crate::PointExpr> { - match self { - FeatureKind::Box { corner: p, .. } - | FeatureKind::Cylinder { base: p, .. } - | FeatureKind::Sphere { center: p, .. } - | FeatureKind::Torus { center: p, .. } - | FeatureKind::Coil { base: p, .. } => Some(p), - _ => None, - } - } - - /// Read-only view of [`FeatureKind::point_expr_mut`]. - pub fn point_expr(&self) -> Option<&crate::PointExpr> { - match self { - FeatureKind::Box { corner: p, .. } - | FeatureKind::Cylinder { base: p, .. } - | FeatureKind::Sphere { center: p, .. } - | FeatureKind::Torus { center: p, .. } - | FeatureKind::Coil { base: p, .. } => Some(p), - _ => None, - } - } } /// One row of the parameters table. diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index c801b912..00df4c06 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -433,6 +433,77 @@ mod tests { assert!((volume(&mut s) - 2.0 * one).abs() < 1e-6, "both blocks are rounded: {m}"); } + /// One body's measures (bounding box, volume). + fn measures(s: &mut Session, body: &str) -> serde_json::Value { + run(s, "inspect.measure", json!({"bodies": [body]}))["bodies"][0].clone() + } + + /// Pasting with a translate moves an extrude's `to` and `from` picks with it: the pasted posts + /// run to the pasted roof and from the pasted shelf, not to and from the originals. + #[test] + fn a_pasted_extrude_runs_to_and_from_the_pasted_faces() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 40, "width": 40, "height": 2, "corner": [0, 0, 20], "name": "Roof", "body_name": "Roof"})); + run(&mut s, "solid.box", json!({"length": 40, "width": 40, "height": 2, "corner": [0, 0, 10], "name": "Shelf", "body_name": "Shelf"})); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [10, 10]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "S", "distance": 5, "to": [7, 7, 20], "operation": "new", "name": "Up", "body_name": "Up"})); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "T"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [25, 25], "p1": [30, 30]})); + run(&mut s, "sketch.finish", json!({})); + run( + &mut s, + "solid.extrude", + json!({"sketch": "T", "distance": 5, "from": [27, 27, 12], "operation": "new", "name": "On", "body_name": "On"}), + ); + run(&mut s, "timeline.copy", json!({"features": ["Roof", "Shelf", "S", "Up", "T", "On"]})); + let r = run(&mut s, "timeline.paste", json!({"translate": [100, 0, 10]})); + assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{r}"); + let up = measures(&mut s, "Up (copy)"); + assert!((up["bbox"]["min"][2].as_f64().unwrap_or(0.0) - 10.0).abs() < 1e-6, "{up}"); + assert!((up["bbox"]["max"][2].as_f64().unwrap_or(0.0) - 30.0).abs() < 1e-6, "it ends on the pasted roof: {up}"); + let on = measures(&mut s, "On (copy)"); + assert!((on["bbox"]["min"][2].as_f64().unwrap_or(0.0) - 22.0).abs() < 1e-6, "it starts on the pasted shelf: {on}"); + assert!((on["bbox"]["max"][2].as_f64().unwrap_or(0.0) - 27.0).abs() < 1e-6, "{on}"); + } + + /// Pasting with a translate moves a hole's `to` pick with it: the pasted through hole still goes + /// through, rather than stopping at the original's depth. + #[test] + fn a_pasted_hole_drills_to_the_moved_point() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 40, "width": 40, "height": 10, "name": "Block", "body_name": "Block"})); + run(&mut s, "solid.hole", json!({"position": [20, 20, 10], "direction": [0, 0, -1], "diameter": 10, "to": [30, 30, -2], "name": "Drill"})); + run(&mut s, "timeline.copy", json!({"features": ["Block", "Drill"]})); + let r = run(&mut s, "timeline.paste", json!({"translate": [60, 0, -6]})); + assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{r}"); + let (block, copy) = (measures(&mut s, "Block"), measures(&mut s, "Block (copy)")); + let v = |m: &serde_json::Value| m["volume_mm3"].as_f64().unwrap_or(f64::NAN); + assert!((v(&block) - (16000.0 - std::f64::consts::PI * 25.0 * 10.0)).abs() < 1.0, "a through hole: {block}"); + assert!((v(©) - v(&block)).abs() < 1e-6, "the pasted hole goes through too: {copy}"); + } + + /// Pasting with a translate moves a revolve's `to` pick with it: the pasted turn ends at the same + /// angle about its moved axis. + #[test] + fn a_pasted_revolve_turns_to_the_moved_point() { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XZ", "name": "S"})); + run(&mut s, "sketch.line", json!({"points": [[10, 0], [15, 0], [15, 5], [10, 5]], "closed": true})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.revolve", json!({"sketch": "S", "axis": "y", "to": [0, 12, 2], "name": "Turn", "body_name": "Arc"})); + run(&mut s, "timeline.copy", json!({"features": ["S", "Turn"]})); + 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 (arc, copy) = (measures(&mut s, "Arc"), measures(&mut s, "Arc (copy)")); + let v = |m: &serde_json::Value| m["volume_mm3"].as_f64().unwrap_or(f64::NAN); + // A quarter turn of a 5 × 5 section whose centre is 12.5 from the axis. + let quarter = 25.0 * std::f64::consts::FRAC_PI_2 * 12.5; + assert!((v(&arc) - quarter).abs() < quarter * 1e-3, "{arc}"); + assert!((v(©) - v(&arc)).abs() < 1e-6, "the pasted arc turns a quarter too: {copy}"); + } + /// 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() {