From 1467ad1d8997a2c885724c61038d92d685289f88 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Wed, 7 Oct 2026 18:29:51 +0000 Subject: [PATCH] M1: rectangular/circular patterns and mirror of features, construction planes (offset, at angle), split body by plane, chamfers by mitred cutters Co-Authored-By: Claude Opus 5.5 --- apps/solvecraft-cli/src/recipe.rs | 35 +++++ crates/doc/src/document.rs | 103 +++++++++++++- crates/doc/src/eval.rs | 223 +++++++++++++++++++++++++++--- crates/engine/src/cmd/features.rs | 153 ++++++++++++++++++++ crates/engine/src/cmd/sketch.rs | 9 +- crates/kernel/src/blend.rs | 119 +++++++++++++++- crates/kernel/src/lib.rs | 2 +- crates/kernel/src/ops.rs | 43 ++++++ crates/kernel/src/tests.rs | 31 +++++ crates/ui-egui/src/icons.rs | 30 ++++ crates/ui-egui/src/timeline.rs | 5 + 11 files changed, 721 insertions(+), 32 deletions(-) diff --git a/apps/solvecraft-cli/src/recipe.rs b/apps/solvecraft-cli/src/recipe.rs index 745c9b3f..4c4aa196 100644 --- a/apps/solvecraft-cli/src/recipe.rs +++ b/apps/solvecraft-cli/src/recipe.rs @@ -209,6 +209,41 @@ pub fn to_script(recipe: &Value) -> Result { "sketch": profile_sketch(f), "profiles": profiles_of(f), "axis": axis, "angle": f.get("angle").cloned().unwrap_or(json!("360 deg")), "operation": op(f), "name": name, "body_names": bodies}})); } + Some("rectangular_pattern") => { + let d1 = f.get("direction1").cloned().unwrap_or(Value::Null); + let d2 = f.get("direction2").cloned().unwrap_or(Value::Null); + let mut p = json!({"features": f.get("features"), "dir1": d1.get("axis"), "count1": d1.get("count"), "spacing1": d1.get("spacing"), "name": name}); + if !d2.is_null() { + p["dir2"] = d2.get("axis").cloned().unwrap_or(Value::Null); + p["count2"] = d2.get("count").cloned().unwrap_or(Value::Null); + p["spacing2"] = d2.get("spacing").cloned().unwrap_or(Value::Null); + } + out.push(json!({"command": "PatternRectangular", "params": p})); + } + Some("circular_pattern") => { + let axis = f.get("axis").map(|a| json!({"origin": a.get("origin").cloned().unwrap_or(json!([0, 0, 0])), "dir": a.get("dir")})).unwrap_or(Value::Null); + out.push(json!({"command": "PatternCircular", "params": {"features": f.get("features"), "axis": axis, "count": f.get("count"), "angle": f.get("total_angle").cloned().unwrap_or(json!(360)), "name": name}})); + } + Some("construction_plane") => match f.get("method").and_then(Value::as_str) { + Some("offset") => out.push(json!({"command": "ConstructionPlaneOffsetFromPlaneCommand", "params": {"base": f.get("base"), "offset": f.get("offset"), "name": name}})), + _ => { + // Use the resulting plane as recorded. + let (o, n) = (f.get("result_origin").cloned().unwrap_or(json!([0, 0, 0])), f.get("result_normal").cloned().unwrap_or(Value::Null)); + if n.is_null() { + return Err(format!("recipe: construction plane method {:?} without result_normal", f.get("method"))); + } + out.push(json!({"command": "ConstructionPlaneOffsetFromPlaneCommand", "params": {"base": {"origin": o, "normal": n}, "offset": 0, "name": name}})); + } + }, + Some("split_body") => out.push(json!({"command": "FusionSplitBodyCommand", "params": {"body": f.get("body"), "plane": f.get("tool"), "name": name}})), + Some("mirror") => { + let pl = f.get("plane").cloned().unwrap_or(Value::Null); + let plane = match pl.get("ref").and_then(Value::as_str) { + Some(r @ ("XY" | "XZ" | "YZ")) => json!(r), + _ => json!({"origin": pl.get("origin").cloned().unwrap_or(json!([0, 0, 0])), "normal": pl.get("normal")}), + }; + out.push(json!({"command": "MirrorCommand", "params": {"features": f.get("features"), "plane": plane, "name": name}})); + } Some("fillet") => { out.push(json!({"command": "FusionFilletEdgesCommand", "params": {"edges": edge_points(f), "radius": f.get("radius"), "name": name}})) } diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index cb43f3b9..3bcde567 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -39,6 +39,10 @@ pub enum PlaneRef { Custom { plane: Plane }, /// Another plane offset along its normal by an expression. Offset { base: Box, distance: String }, + /// Another plane rotated about a line (in world coordinates) by an angle expression. + AtAngle { base: Box, axis_origin: Vec3, axis_dir: Vec3, angle: String }, + /// A construction plane feature (by name). + Construction { name: String }, } /// Which closed profiles of a sketch a feature uses. @@ -202,6 +206,25 @@ pub enum FeatureKind { #[serde(default)] keep_tools: bool, }, + /// Copies of other features' results (rectangular or circular). + Pattern { + features: Vec, + pattern: PatternKind, + }, + /// Mirror copies of other features' results in a plane. + Mirror { + features: Vec, + plane: PlaneRef, + }, + /// A construction plane (sketch placement, split tool). + ConstructionPlane { + plane: PlaneRef, + }, + /// Split a body with a plane into two bodies. + Split { + body: String, + plane: PlaneRef, + }, Move { bodies: Vec, translate: [String; 3], @@ -212,6 +235,47 @@ pub enum FeatureKind { }, } +/// Pattern layouts. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum PatternKind { + Rectangular { + dir1: Vec3, + count1: String, + spacing1: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + dir2: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + count2: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + spacing2: Option, + }, + Circular { + origin: Vec3, + axis: Vec3, + count: String, + /// Total angle (360 deg = evenly around). + angle: String, + }, +} + +fn plane_exprs<'a>(p: &'a PlaneRef, v: &mut Vec<&'a str>) { + let mut p = p; + for _ in 0..64 { + match p { + PlaneRef::Offset { base, distance } => { + v.push(distance); + p = base; + } + PlaneRef::AtAngle { base, angle, .. } => { + v.push(angle); + p = base; + } + _ => break, + } + } +} + fn z_axis() -> Vec3 { Vec3::Z } @@ -230,6 +294,11 @@ impl FeatureKind { FeatureKind::Sphere { .. } => "SphereFeature", FeatureKind::Torus { .. } => "TorusFeature", FeatureKind::Combine { .. } => "CombineFeature", + FeatureKind::Pattern { pattern: PatternKind::Rectangular { .. }, .. } => "RectangularPatternFeature", + FeatureKind::Pattern { .. } => "CircularPatternFeature", + FeatureKind::Mirror { .. } => "MirrorFeature", + FeatureKind::ConstructionPlane { .. } => "ConstructionPlane", + FeatureKind::Split { .. } => "SplitBodyFeature", FeatureKind::Move { .. } => "MoveFeature", } } @@ -246,6 +315,11 @@ impl FeatureKind { FeatureKind::Sphere { .. } => "Sphere", FeatureKind::Torus { .. } => "Torus", FeatureKind::Combine { .. } => "Combine", + FeatureKind::Pattern { pattern: PatternKind::Rectangular { .. }, .. } => "RectangularPattern", + FeatureKind::Pattern { .. } => "CircularPattern", + FeatureKind::Mirror { .. } => "Mirror", + FeatureKind::ConstructionPlane { .. } => "Plane", + FeatureKind::Split { .. } => "Split", FeatureKind::Move { .. } => "Move", } } @@ -253,12 +327,8 @@ impl FeatureKind { pub fn expressions(&self) -> Vec<&str> { let mut v: Vec<&str> = Vec::new(); match self { - FeatureKind::Sketch { plane, .. } => { - let mut p = plane; - while let PlaneRef::Offset { base, distance } = p { - v.push(distance); - p = base; - } + FeatureKind::Sketch { plane, .. } | FeatureKind::ConstructionPlane { plane } | FeatureKind::Split { plane, .. } => { + plane_exprs(plane, &mut v) } FeatureKind::Extrude { extent, .. } => { v.push(&extent.distance); @@ -280,6 +350,15 @@ impl FeatureKind { FeatureKind::Sphere { radius, .. } => v.push(radius), FeatureKind::Torus { major, minor, .. } => v.extend([major.as_str(), minor]), FeatureKind::Combine { .. } => {} + FeatureKind::Pattern { pattern, .. } => match pattern { + PatternKind::Rectangular { count1, spacing1, count2, spacing2, .. } => { + v.extend([count1.as_str(), spacing1]); + v.extend(count2.iter().map(String::as_str)); + v.extend(spacing2.iter().map(String::as_str)); + } + PatternKind::Circular { count, angle, .. } => v.extend([count.as_str(), angle]), + }, + FeatureKind::Mirror { plane, .. } => plane_exprs(plane, &mut v), FeatureKind::Move { translate, angle, .. } => { v.extend(translate.iter().map(String::as_str)); if let Some(a) = angle { @@ -561,6 +640,18 @@ impl Document { let b = self.resolve_plane(vals, base, depth + 1)?; Ok(b.offset(Self::eval_in(vals, distance, Kind::Length)?)) } + PlaneRef::AtAngle { base, axis_origin, axis_dir, angle } => { + let b = self.resolve_plane(vals, base, depth + 1)?; + let a = Self::eval_in(vals, angle, Kind::Angle)?; + let d = axis_dir.normalized().ok_or_else(|| DocError::Invalid("rotation axis".into()))?; + let rot = |v: Vec3| v * a.cos() + d.cross(v) * a.sin() + d * (d.dot(v) * (1.0 - a.cos())); + let o = *axis_origin + rot(b.origin - *axis_origin); + Plane::new(o, rot(b.x), rot(b.y)).ok_or_else(|| DocError::Invalid("degenerate plane".into())) + } + PlaneRef::Construction { name } => match self.find_feature(name).map(|f| &f.kind) { + Some(FeatureKind::ConstructionPlane { plane }) => self.resolve_plane(vals, plane, depth + 1), + _ => Err(DocError::Unknown(format!("construction plane `{name}`"))), + }, } } diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 00ecb259..6e14cac8 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -464,21 +464,9 @@ fn revolve_axis(ss: &SolvedSketch, axis: &AxisRef) -> Result<(Vec2, Vec2)> { } } -fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: &mut ModelState, warning: &mut Option) -> Result<()> { +/// The tool bodies a feature adds or removes (extrude, revolve, primitives), in the current state. +fn feature_tools(vals: &BTreeMap, f: &Feature, st: &ModelState) -> Result> { match &f.kind { - FeatureKind::Sketch { plane, sketch } => { - let plane = doc.resolve_plane(vals, plane, 0)?; - let mut sk = sketch.clone(); - doc.apply_dimension_values(vals, &mut sk)?; - let report = solve(&mut sk); - if !report.ok() { - *warning = Some(format!("the sketch constraints conflict ({})", report.failing.join(", "))); - } - let profiles = find_profiles(&sk); - st.sketches.retain(|s| s.feature != f.id); - st.sketches.push(SolvedSketch { feature: f.id, name: f.name.clone(), plane, sketch: sk, report, profiles }); - Ok(()) - } FeatureKind::Extrude { sketch, profiles, extent, operation, targets } => { let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?.clone(); let regions = solvecraft_sketch::merge_regions(&select_profiles(&ss, profiles)?); @@ -490,9 +478,10 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: } let sign = if extent.direction == crate::Direction::Negative { -1.0 } else { 1.0 }; let (len, sign) = if d < 0.0 { (-d, -sign) } else { (d, sign) }; - let tools = - regions.iter().map(|r| kernel::extrude_tapered(&ss.plane, r, len, sign, taper)).collect::, _>>()?; - return apply_op(st, f, tools, *operation, targets); + return Ok(regions + .iter() + .map(|r| kernel::extrude_tapered(&ss.plane, r, len, sign, taper)) + .collect::, _>>()?); } let off = match &extent.start_offset { Some(e) => val(vals, e, Kind::Length)?, @@ -511,16 +500,210 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: let (l2, h2) = extend_for_coplanar(st, &ss.plane, r, lo + off, hi + off, *operation, targets); tools.extend(kernel::extrude(&ss.plane, std::slice::from_ref(r), l2, h2)?); } - apply_op(st, f, tools, *operation, targets) + Ok(tools) } - FeatureKind::Revolve { sketch, profiles, axis, angle, operation, targets } => { + FeatureKind::Revolve { sketch, profiles, axis, angle, .. } => { let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?.clone(); let regions = solvecraft_sketch::merge_regions(&select_profiles(&ss, profiles)?); let (o, d) = revolve_axis(&ss, axis)?; let ang = val(vals, angle, Kind::Angle)?; - let tools = kernel::revolve(&ss.plane, ®ions, o, d, ang)?; + Ok(kernel::revolve(&ss.plane, ®ions, o, d, ang)?) + } + FeatureKind::Box { corner, length, width, height, .. } => { + let s = Vec3::new(val(vals, length, Kind::Length)?, val(vals, width, Kind::Length)?, val(vals, height, Kind::Length)?); + Ok(vec![kernel::box_solid(*corner, *corner + s)?]) + } + FeatureKind::Cylinder { base, axis, radius, height, .. } => { + Ok(vec![kernel::cylinder(*base, *axis, val(vals, radius, Kind::Length)?, val(vals, height, Kind::Length)?)?]) + } + FeatureKind::Sphere { center, radius, .. } => Ok(vec![kernel::sphere(*center, val(vals, radius, Kind::Length)?)?]), + FeatureKind::Torus { center, major, minor, .. } => { + Ok(vec![kernel::torus(*center, val(vals, major, Kind::Length)?, val(vals, minor, Kind::Length)?)?]) + } + _ => Err(DocError::Invalid(format!("{} cannot be patterned or mirrored yet", f.name))), + } +} + +/// Operation and targets of a feature that makes tools. +fn feature_op(f: &Feature) -> (Operation, Vec) { + match &f.kind { + FeatureKind::Extrude { operation, targets, .. } | FeatureKind::Revolve { operation, targets, .. } => (*operation, targets.clone()), + FeatureKind::Box { operation, .. } + | FeatureKind::Cylinder { operation, .. } + | FeatureKind::Sphere { operation, .. } + | FeatureKind::Torus { operation, .. } => (*operation, Vec::new()), + _ => (Operation::NewBody, Vec::new()), + } +} + +type Mat = [[f64; 4]; 4]; + +fn translation(t: Vec3) -> Mat { + [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [t.x, t.y, t.z, 1.0]] +} + +/// Rotation by `ang` about the line through `o` with direction `a` (column-major). +fn rotation(o: Vec3, a: Vec3, ang: f64) -> Mat { + let a = a.normalized().unwrap_or(Vec3::Z); + let (c, s) = (ang.cos(), ang.sin()); + let t = 1.0 - c; + let r = [ + [t * a.x * a.x + c, t * a.x * a.y + s * a.z, t * a.x * a.z - s * a.y], + [t * a.x * a.y - s * a.z, t * a.y * a.y + c, t * a.y * a.z + s * a.x], + [t * a.x * a.z + s * a.y, t * a.y * a.z - s * a.x, t * a.z * a.z + c], + ]; + // p' = R (p - o) + o + let ro = Vec3::new( + r[0][0] * o.x + r[1][0] * o.y + r[2][0] * o.z, + r[0][1] * o.x + r[1][1] * o.y + r[2][1] * o.z, + r[0][2] * o.x + r[1][2] * o.y + r[2][2] * o.z, + ); + let tr = o - ro; + [[r[0][0], r[0][1], r[0][2], 0.0], [r[1][0], r[1][1], r[1][2], 0.0], [r[2][0], r[2][1], r[2][2], 0.0], [tr.x, tr.y, tr.z, 1.0]] +} + +/// Reflection in a plane (column-major). +fn mirror_matrix(pl: &Plane) -> Mat { + let n = pl.normal(); + let d = pl.origin.dot(n); + let m = |i: usize, j: usize| { + let (ni, nj) = ([n.x, n.y, n.z][i], [n.x, n.y, n.z][j]); + (if i == j { 1.0 } else { 0.0 }) - 2.0 * ni * nj + }; + // p' = p - 2 (p·n - d) n + [ + [m(0, 0), m(0, 1), m(0, 2), 0.0], + [m(1, 0), m(1, 1), m(1, 2), 0.0], + [m(2, 0), m(2, 1), m(2, 2), 0.0], + [2.0 * d * n.x, 2.0 * d * n.y, 2.0 * d * n.z, 1.0], + ] +} + +const MAX_INSTANCES: usize = 1000; + +fn pattern_transforms(vals: &BTreeMap, p: &crate::PatternKind) -> Result> { + let count = |e: &str| -> Result { + let n = val(vals, e, Kind::Unitless)?.round(); + if !(1.0..=MAX_INSTANCES as f64).contains(&n) { + return Err(DocError::Invalid(format!("pattern count must be 1…{MAX_INSTANCES}"))); + } + Ok(n as usize) + }; + let mut out = Vec::new(); + match p { + crate::PatternKind::Rectangular { dir1, count1, spacing1, dir2, count2, spacing2 } => { + let (n1, s1) = (count(count1)?, val(vals, spacing1, Kind::Length)?); + let d1 = dir1.normalized().ok_or_else(|| DocError::Invalid("pattern direction".into()))?; + let (n2, s2, d2) = match (dir2, count2, spacing2) { + (Some(d), Some(c), Some(sp)) => { + (count(c)?, val(vals, sp, Kind::Length)?, d.normalized().ok_or_else(|| DocError::Invalid("pattern direction".into()))?) + } + _ => (1, 0.0, Vec3::Y), + }; + if n1 * n2 > MAX_INSTANCES { + return Err(DocError::Invalid("too many pattern instances".into())); + } + for i in 0..n1 { + for j in 0..n2 { + if i == 0 && j == 0 { + continue; + } + out.push(translation(d1 * (s1 * i as f64) + d2 * (s2 * j as f64))); + } + } + } + crate::PatternKind::Circular { origin, axis, count: c, angle } => { + let n = count(c)?; + let total = val(vals, angle, Kind::Angle)?; + let full = (total.abs() - std::f64::consts::TAU).abs() < 1e-9; + let step = if full { + total / n as f64 + } else if n > 1 { + total / (n - 1) as f64 + } else { + 0.0 + }; + for i in 1..n { + out.push(rotation(*origin, *axis, step * i as f64)); + } + } + } + Ok(out) +} + +/// Apply transformed copies of the tools of `features` (patterns, mirrors). +fn replay(doc: &Document, vals: &BTreeMap, f: &Feature, st: &mut ModelState, features: &[String], mats: &[Mat]) -> Result<()> { + if features.is_empty() { + return Err(DocError::Invalid("no features selected".into())); + } + for name in features { + let src = doc.find_feature(name).ok_or_else(|| DocError::Unknown(format!("feature `{name}`")))?; + let tools = feature_tools(vals, src, st)?; + let (op, targets) = feature_op(src); + let mut copies = Vec::new(); + for m in mats { + for t in &tools { + copies.push(kernel::transform_matrix(t, *m)?); + } + } + // New bodies from a pattern join their source when they touch it (as one feature would). + apply_op(st, f, copies, op, &targets)?; + } + Ok(()) +} + +fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: &mut ModelState, warning: &mut Option) -> Result<()> { + match &f.kind { + FeatureKind::Sketch { plane, sketch } => { + let plane = doc.resolve_plane(vals, plane, 0)?; + let mut sk = sketch.clone(); + doc.apply_dimension_values(vals, &mut sk)?; + let report = solve(&mut sk); + if !report.ok() { + *warning = Some(format!("the sketch constraints conflict ({})", report.failing.join(", "))); + } + let profiles = find_profiles(&sk); + st.sketches.retain(|s| s.feature != f.id); + st.sketches.push(SolvedSketch { feature: f.id, name: f.name.clone(), plane, sketch: sk, report, profiles }); + Ok(()) + } + FeatureKind::Extrude { operation, targets, .. } | FeatureKind::Revolve { operation, targets, .. } => { + let tools = feature_tools(vals, f, st)?; apply_op(st, f, tools, *operation, targets) } + FeatureKind::ConstructionPlane { plane } => { + doc.resolve_plane(vals, plane, 0)?; + Ok(()) + } + FeatureKind::Split { body, plane } => { + let pl = doc.resolve_plane(vals, plane, 0)?; + let i = st.bodies.iter().position(|b| &b.name == body).ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?; + let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Unknown(format!("body `{body}`"))) }; + let parts = kernel::split_by_plane(&mb.body, &pl)?; + if parts.len() < 2 { + return Err(DocError::Invalid("the plane does not split the body".into())); + } + let mut it = parts.into_iter(); + if let (Some(first), Some(slot)) = (it.next(), st.bodies.get_mut(i)) { + *slot = ModelBody::new(mb.name.clone(), first, mb.feature); + } + for (k, b) in it.enumerate() { + let mut name = format!("{} ({})", mb.name, k + 1); + while st.body(&name).is_some() { + name.push('\''); + } + st.bodies.push(ModelBody::new(name, b, f.id)); + } + Ok(()) + } + FeatureKind::Pattern { features, pattern } => { + let mats = pattern_transforms(vals, pattern)?; + replay(doc, vals, f, st, features, &mats) + } + FeatureKind::Mirror { features, plane } => { + let pl = doc.resolve_plane(vals, plane, 0)?; + replay(doc, vals, f, st, features, &[mirror_matrix(&pl)]) + } FeatureKind::Fillet { edges, radius, body } | FeatureKind::Chamfer { edges, distance: radius, body } => { let r = val(vals, radius, Kind::Length)?; let ti = blend_target(st, body, edges)?; diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index fddddc01..3172f0b8 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -29,6 +29,24 @@ pub static COMMANDS: &[CommandSpec] = &[ .params("edges: [[x,y,z] point on edge | {body, index}]; radius: expr; body?"), CommandSpec::new("FusionChamferCommand", "Chamfer", chamfer).at("SOLID", "MODIFY").icon("chamfer").params("edges: [[x,y,z] | {body, index}]; distance: expr; body?"), CommandSpec::new("FusionCombineCommand", "Combine", combine).at("SOLID", "MODIFY").icon("combine").params("target: body; tools: [body]; operation?: join|cut|intersect; keep_tools?: bool"), + CommandSpec::new("PatternRectangular", "Rectangular Pattern", pattern_rect) + .at("SOLID", "CREATE") + .icon("pattern_rect") + .params("features: [names]; dir1: [x,y,z]; count1; spacing1; dir2?, count2?, spacing2?"), + CommandSpec::new("PatternCircular", "Circular Pattern", pattern_circ) + .at("SOLID", "CREATE") + .icon("pattern_circ") + .params("features: [names]; axis: X|Y|Z | {origin, dir}; count; angle? (default 360 deg)"), + CommandSpec::new("MirrorCommand", "Mirror", mirror).at("SOLID", "CREATE").icon("mirror").params("features: [names]; plane: XY|XZ|YZ | {origin, x_dir, y_dir}"), + CommandSpec::new("ConstructionPlaneOffsetFromPlaneCommand", "Offset Plane", plane_offset) + .at("SOLID", "CONSTRUCT") + .icon("plane") + .params("base: XY|XZ|YZ|plane name; offset: expr; name?"), + CommandSpec::new("ConstructionPlaneAtAngleCommand", "Plane at Angle", plane_angle) + .at("SOLID", "CONSTRUCT") + .icon("plane") + .params("base: XY|XZ|YZ|plane name; axis: X|Y|Z|{origin, dir}; angle: expr; name?"), + CommandSpec::new("FusionSplitBodyCommand", "Split Body", split_body).at("SOLID", "MODIFY").icon("split").params("body: name; plane: XY|XZ|YZ|plane name|{origin, normal}"), CommandSpec::new("FusionMoveCommand", "Move/Copy", move_bodies).at("SOLID", "MODIFY").icon("move").key("M").params("bodies: [names]; translate?: [x,y,z] (exprs or numbers); axis?: [x,y,z]; angle?: expr"), ]; @@ -329,3 +347,138 @@ fn move_bodies(s: &mut Session, p: &Value) -> Result { } add_feature(s, p, FeatureKind::Move { bodies, translate: t, rotate_axis: p.get("axis").and_then(vec3), angle }) } + +fn source_features(s: &Session, p: &Value, cmd: &str) -> Result> { + let names = string_list(p, "features"); + if names.is_empty() { + return Err(bad(cmd, "`features` must list features to copy")); + } + for n in &names { + if s.doc.find_feature(n).is_none() { + return Err(bad(cmd, format!("no feature `{n}`"))); + } + } + Ok(names) +} + +fn axis_line(p: &Value, cmd: &str) -> Result<(Vec3, Vec3)> { + match p.get("axis") { + Some(Value::String(a)) => match a.to_ascii_uppercase().as_str() { + "X" => Ok((Vec3::ZERO, Vec3::X)), + "Y" => Ok((Vec3::ZERO, Vec3::Y)), + "Z" => Ok((Vec3::ZERO, Vec3::Z)), + _ => Err(bad(cmd, "axis must be X, Y, Z or {origin, dir}")), + }, + Some(o @ Value::Object(_)) => { + let origin = o.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO); + let dir = o.get("dir").and_then(vec3).and_then(|d| d.normalized()).ok_or_else(|| bad(cmd, "axis needs a non-zero `dir`"))?; + Ok((origin, dir)) + } + _ => Err(bad(cmd, "`axis` is required")), + } +} + +fn pattern_rect(s: &mut Session, p: &Value) -> Result { + let cmd = "PatternRectangular"; + let features = source_features(s, p, cmd)?; + let dir1 = p.get("dir1").and_then(vec3).ok_or_else(|| bad(cmd, "`dir1` must be [x, y, z]"))?; + let count1 = req_expr(cmd, p, "count1")?; + let spacing1 = req_expr(cmd, p, "spacing1")?; + check_expr(s, &count1, Kind::Unitless, cmd, "count1")?; + check_expr(s, &spacing1, Kind::Length, cmd, "spacing1")?; + let dir2 = p.get("dir2").and_then(vec3); + let (count2, spacing2) = (expr(p, "count2"), expr(p, "spacing2")); + if let Some(c) = &count2 { + check_expr(s, c, Kind::Unitless, cmd, "count2")?; + } + if let Some(c) = &spacing2 { + check_expr(s, c, Kind::Length, cmd, "spacing2")?; + } + let pattern = solvecraft_doc::PatternKind::Rectangular { dir1, count1, spacing1, dir2, count2, spacing2 }; + add_feature(s, p, FeatureKind::Pattern { features, pattern }) +} + +fn pattern_circ(s: &mut Session, p: &Value) -> Result { + let cmd = "PatternCircular"; + let features = source_features(s, p, cmd)?; + let (origin, axis) = axis_line(p, cmd)?; + let count = req_expr(cmd, p, "count")?; + check_expr(s, &count, Kind::Unitless, cmd, "count")?; + let angle = expr(p, "angle").unwrap_or_else(|| "360 deg".into()); + check_expr(s, &angle, Kind::Angle, cmd, "angle")?; + let pattern = solvecraft_doc::PatternKind::Circular { origin, axis, count, angle }; + add_feature(s, p, FeatureKind::Pattern { features, pattern }) +} + +fn mirror(s: &mut Session, p: &Value) -> Result { + let cmd = "MirrorCommand"; + let features = source_features(s, p, cmd)?; + let plane = match p.get("plane") { + Some(Value::String(n)) if solvecraft_geom::Plane::named(n).is_some() => solvecraft_doc::PlaneRef::Origin { name: n.to_ascii_uppercase() }, + Some(o @ Value::Object(_)) => { + let origin = o.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO); + let pl = match (o.get("x_dir").and_then(vec3), o.get("y_dir").and_then(vec3), o.get("normal").and_then(vec3)) { + (Some(x), Some(y), _) => solvecraft_geom::Plane::new(origin, x, y), + (_, _, Some(n)) => solvecraft_geom::Plane::from_normal(origin, n), + _ => None, + }; + solvecraft_doc::PlaneRef::Custom { plane: pl.ok_or_else(|| bad(cmd, "plane needs x_dir and y_dir, or normal"))? } + } + _ => return Err(bad(cmd, "`plane` must be XY, XZ, YZ or {origin, normal}")), + }; + add_feature(s, p, FeatureKind::Mirror { features, plane }) +} + +/// A plane reference from a parameter: an origin plane, a construction plane name, or an +/// explicit plane `{origin, x_dir, y_dir}` / `{origin, normal}`. +pub fn plane_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result { + use solvecraft_doc::PlaneRef; + match v { + Some(Value::String(n)) if solvecraft_geom::Plane::named(n).is_some() => Ok(PlaneRef::Origin { name: n.to_ascii_uppercase() }), + Some(Value::String(n)) => match s.doc.find_feature(n).map(|f| &f.kind) { + Some(FeatureKind::ConstructionPlane { .. }) => Ok(PlaneRef::Construction { name: n.clone() }), + _ => Err(bad(cmd, format!("no plane `{n}`"))), + }, + Some(o @ Value::Object(_)) => { + let origin = o.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO); + let pl = match (o.get("x_dir").and_then(vec3), o.get("y_dir").and_then(vec3), o.get("normal").and_then(vec3)) { + (Some(x), Some(y), _) => solvecraft_geom::Plane::new(origin, x, y), + (_, _, Some(n)) => solvecraft_geom::Plane::from_normal(origin, n), + _ => None, + }; + Ok(PlaneRef::Custom { plane: pl.ok_or_else(|| bad(cmd, "plane needs x_dir and y_dir, or normal"))? }) + } + _ => Err(bad(cmd, "`plane` must be XY, XZ, YZ, a construction plane name or {origin, normal}")), + } +} + +fn plane_offset(s: &mut Session, p: &Value) -> Result { + let cmd = "ConstructionPlaneOffsetFromPlaneCommand"; + let base = plane_param(s, p.get("base"), cmd)?; + let d = req_expr(cmd, p, "offset")?; + check_expr(s, &d, Kind::Length, cmd, "offset")?; + add_feature(s, p, FeatureKind::ConstructionPlane { plane: solvecraft_doc::PlaneRef::Offset { base: Box::new(base), distance: d } }) +} + +fn plane_angle(s: &mut Session, p: &Value) -> Result { + let cmd = "ConstructionPlaneAtAngleCommand"; + let base = plane_param(s, p.get("base"), cmd)?; + let (axis_origin, axis_dir) = axis_line(p, cmd)?; + let angle = req_expr(cmd, p, "angle")?; + check_expr(s, &angle, Kind::Angle, cmd, "angle")?; + add_feature( + s, + p, + FeatureKind::ConstructionPlane { plane: solvecraft_doc::PlaneRef::AtAngle { base: Box::new(base), axis_origin, axis_dir, angle } }, + ) +} + +fn split_body(s: &mut Session, p: &Value) -> Result { + let cmd = "FusionSplitBodyCommand"; + let body = str_(p, "body").ok_or_else(|| bad(cmd, "`body` is required"))?.to_string(); + if s.model.state().body(&body).is_none() { + return Err(bad(cmd, format!("no body `{body}`"))); + } + let plane = plane_param(s, p.get("plane"), cmd)?; + add_feature(s, p, FeatureKind::Split { body, plane }) +} diff --git a/crates/engine/src/cmd/sketch.rs b/crates/engine/src/cmd/sketch.rs index b494447a..37f2f9ff 100644 --- a/crates/engine/src/cmd/sketch.rs +++ b/crates/engine/src/cmd/sketch.rs @@ -286,10 +286,13 @@ fn plane_ref(s: &Session, p: &Value, cmd: &str) -> Result { let base = match p.get("plane") { None => PlaneRef::Origin { name: "XY".into() }, Some(Value::String(n)) => { - if Plane::named(n).is_none() { - return Err(bad(cmd, format!("unknown plane `{n}` (XY, XZ or YZ)"))); + if Plane::named(n).is_some() { + PlaneRef::Origin { name: n.to_ascii_uppercase() } + } else if matches!(s.doc.find_feature(n).map(|f| &f.kind), Some(FeatureKind::ConstructionPlane { .. })) { + PlaneRef::Construction { name: n.clone() } + } else { + return Err(bad(cmd, format!("unknown plane `{n}` (XY, XZ, YZ or a construction plane)"))); } - PlaneRef::Origin { name: n.to_ascii_uppercase() } } Some(v @ Value::Object(o)) => { if let Some(fp) = o.get("face").and_then(vec3) { diff --git a/crates/kernel/src/blend.rs b/crates/kernel/src/blend.rs index 7cd4102c..3e94d10b 100644 --- a/crates/kernel/src/blend.rs +++ b/crates/kernel/src/blend.rs @@ -301,7 +301,122 @@ pub fn fillet(body: &Body, edges: &[Vec3], radius: f64) -> Result { blend(body, edges, radius, Shape::Round, "fillet") } -/// Equal-distance chamfer of the edges nearest to the given points. +/// Equal-distance chamfer of the edges nearest to the given points. Each edge is cut with a +/// prism whose only face inside the body is the chamfer plane (so neighbouring chamfers meet in +/// a mitre); the local operation is the fallback. pub fn chamfer(body: &Body, edges: &[Vec3], distance: f64) -> Result { - blend(body, edges, distance, Shape::Flat, "chamfer") + if !(distance.is_finite() && distance > 1e-6 && distance < 1e6) { + return Err(KernelError::Invalid("chamfer size must be positive".into())); + } + if edges.is_empty() || edges.len() > 1000 { + return Err(KernelError::Invalid("select 1…1000 edges".into())); + } + // All cutters at once (their union mitres the corners), else one edge at a time. + let tools: Result> = edges.iter().enumerate().map(|(i, p)| chamfer_tool(body, *p, distance, i)).collect(); + if let Ok(tools) = tools { + let mut acc: Option = None; + let mut ok = true; + for t in tools { + let next = match &acc { + None => Some(t), + Some(a) => crate::ops::boolean(a, &t, crate::BoolOp::Union).ok().flatten(), + }; + if next.is_none() { + ok = false; + break; + } + acc = next; + } + if ok + && let Some(tool) = acc + && let Ok(Some(r)) = crate::ops::boolean(body, &tool, crate::BoolOp::Cut) + { + return Ok(r); + } + } + let mut cur = body.clone(); + for p in edges { + cur = match chamfer_tool(&cur, *p, distance, 0).and_then(|t| crate::ops::boolean(&cur, &t, crate::BoolOp::Cut)) { + Ok(Some(b)) => b, + _ => blend(&cur, std::slice::from_ref(p), distance, Shape::Flat, "chamfer")?, + }; + } + Ok(cur) +} + +/// Geometry of a straight convex edge between two planar faces. +struct EdgeFrame { + p0: Vec3, + p1: Vec3, + d: Vec3, + n1: Vec3, + n2: Vec3, + t1: Vec3, + t2: Vec3, +} + +fn edge_frame(cur: &Body, p: Vec3) -> Result { + let size = cur.size(); + let tol = (size * 2e-3).max(1e-3); + let Some((idx, dist)) = cur.nearest_edge(p, tol)? else { return Err(KernelError::Invalid("the body has no edges".into())) }; + if dist > size * 0.05 + 1e-3 { + return Err(KernelError::Invalid(format!("no edge near {:?}", [p.x, p.y, p.z]))); + } + let solid = &*cur.solid; + let edge = Body::unique_edges(solid).into_iter().nth(idx).ok_or_else(|| KernelError::Invalid("edge index".into()))?; + if !matches!(edge.curve(), mt::Curve::Line(_)) { + return Err(unsupported("only straight edges")); + } + let faces: Vec<&mt::Face> = solid.face_iter().filter(|f| f.edge_iter().any(|e| e.id() == edge.id())).collect(); + let [f1, f2] = faces[..] else { return Err(unsupported("the edge is not between two faces")) }; + let (Some(n1), Some(n2)) = (plane_normal(f1), plane_normal(f2)) else { return Err(unsupported("both faces next to the edge must be planar")) }; + let (p0, p1) = (vtx(edge.absolute_front()), vtx(edge.absolute_back())); + let d = (p1 - p0).normalized().ok_or_else(|| KernelError::Failed("zero-length edge".into()))?; + let mut t1 = n1.cross(d); + if t1.dot(n2) > 0.0 { + t1 = -t1; + } + let mut t2 = n2.cross(d); + if t2.dot(n1) > 0.0 { + t2 = -t2; + } + if n1.dot(t2) > 1e-9 || n2.dot(t1) > 1e-9 || t1.dot(t2) < -1.0 + 1e-6 { + return Err(unsupported("only convex edges")); + } + Ok(EdgeFrame { p0, p1, d, n1, n2, t1, t2 }) +} + +/// The prism that cuts a chamfer of size `s` on the edge nearest `p`. `k` varies the parts that +/// stay outside the body so that neighbouring cutters don't share edges. +pub(crate) fn chamfer_tool(cur: &Body, p: Vec3, s: f64, k: usize) -> Result { + let f = edge_frame(cur, p)?; + let size = cur.size(); + let len = f.p0.dist(f.p1); + let ext = (s * 2.0).max(size * 0.01) * (1.0 + 0.093 * (k % 5) as f64); + // Cross-section at p0 - d*ext, in a plane with normal d. + let base = f.p0 - f.d * ext; + let plane = solvecraft_geom::Plane::from_normal(base, f.d).ok_or_else(|| KernelError::Failed("chamfer plane".into()))?; + let a = base + f.t1 * s; + let b = base + f.t2 * s; + let ab = (a - b).normalized().ok_or_else(|| KernelError::Failed("chamfer".into()))?; + let out = (f.n1 + f.n2).normalized().ok_or_else(|| KernelError::Failed("chamfer".into()))?; + let vary = 1.0 + 0.137 * (k % 7) as f64; + let reach = (s * 4.0 + size * 0.02) * vary; + let a_ext = a + ab * (s * 0.5 + reach * 0.05); + let b_ext = b - ab * (s * 0.5 + reach * 0.07); + let e_far = base + out * reach; + // The material beyond both ends must be empty (the cutter runs past them). + let mid = (a + b + base) / 3.0; + for probe in [mid + f.d * (ext * 0.5), mid + f.d * (len + ext * 1.5)] { + if cur.contains(probe) { + return Err(unsupported("chamfers ending at a concave corner")); + } + } + let loc: Vec = [a_ext, b_ext, e_far].iter().map(|q| plane.to_local(*q)).collect(); + let mut lp = solvecraft_geom::Loop2::polygon(&loc); + if lp.signed_area() < 0.0 { + lp = lp.reversed(); + } + let region = solvecraft_geom::Region2 { outer: lp, holes: vec![] }; + crate::build::extrude(&plane, &[region], 0.0, len + 2.0 * ext)?.pop().ok_or_else(|| KernelError::Failed("chamfer tool".into())) } diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 7ff8a02c..3460473d 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -24,7 +24,7 @@ pub use blend::{chamfer, fillet}; pub use body::{Body, EdgeInfo, FaceInfo}; pub use build::{box_solid, cylinder, extrude, extrude_tapered, revolve, sphere, torus}; pub use measure::{BodyMeasure, measure}; -pub use ops::{BoolOp, boolean, transform}; +pub use ops::{BoolOp, boolean, split_by_plane, transform, transform_matrix}; pub use step::step_export; pub use topo::{TopoCounts, merged_topology, seam_flags}; diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs index 42eec355..32e486bf 100644 --- a/crates/kernel/src/ops.rs +++ b/crates/kernel/src/ops.rs @@ -84,6 +84,20 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result> { Err(KernelError::Failed(format!("boolean {op:?}: {last}"))) } +/// Affine transform by a column-major 4×4 matrix (rotations, translations, reflections). +pub fn transform_matrix(body: &Body, m: [[f64; 4]; 4]) -> Result { + if m.iter().flatten().any(|x| !x.is_finite()) { + return Err(KernelError::Invalid("non-finite transform".into())); + } + guard("transform", || { + let mat = mt::Matrix4::new( + m[0][0], m[0][1], m[0][2], m[0][3], m[1][0], m[1][1], m[1][2], m[1][3], m[2][0], m[2][1], m[2][2], m[2][3], m[3][0], m[3][1], m[3][2], + m[3][3], + ); + Body::new(mt::builder::transformed(&body.deep_copy(), mat)) + }) +} + /// Rigid transform: rotate about `axis` through `origin` by `angle` radians, then translate. pub fn transform(body: &Body, translate: Vec3, origin: Vec3, axis: Vec3, angle: f64) -> Result { if !(translate.is_finite() && origin.is_finite() && angle.is_finite()) { @@ -101,3 +115,32 @@ pub fn transform(body: &Body, translate: Vec3, origin: Vec3, axis: Vec3, angle: Body::new(s) }) } + +/// Split a body with a plane: the parts on the positive and the negative side of the plane +/// (only the non-empty ones). +pub fn split_by_plane(body: &Body, plane: &solvecraft_geom::Plane) -> Result> { + let size = body.size(); + let mut bb = solvecraft_geom::Aabb3::EMPTY; + for v in body.solid.vertex_iter() { + bb.add(crate::body::from_p3(v.point())); + } + let reach = (bb.diagonal() + plane.origin.dist(bb.center())) * 2.0 + size; + let c = plane.to_local(bb.center()); + let sq = solvecraft_geom::Region2 { + outer: solvecraft_geom::Loop2::polygon(&[ + solvecraft_geom::Vec2::new(c.x - reach, c.y - reach), + solvecraft_geom::Vec2::new(c.x + reach, c.y - reach), + solvecraft_geom::Vec2::new(c.x + reach, c.y + reach), + solvecraft_geom::Vec2::new(c.x - reach, c.y + reach), + ]), + holes: vec![], + }; + let mut out = Vec::new(); + for (lo, hi) in [(0.0, reach), (-reach, 0.0)] { + let half = crate::build::extrude(plane, std::slice::from_ref(&sq), lo, hi)?.pop().ok_or_else(|| KernelError::Failed("half space".into()))?; + if let Some(part) = boolean(body, &half, BoolOp::Intersect)? { + out.push(part); + } + } + Ok(out) +} diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs index 01d8a327..2f1d3dea 100644 --- a/crates/kernel/src/tests.rs +++ b/crates/kernel/src/tests.rs @@ -264,3 +264,34 @@ fn debug_coplanar_union_rate() { } println!("ok {ok}/60"); } + +#[test] +fn chamfer_top_loop_mitres() { + let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap(); + let edges = [Vec3::new(0.0, 15.0, 20.0), Vec3::new(20.0, 0.0, 20.0), Vec3::new(40.0, 15.0, 20.0), Vec3::new(20.0, 30.0, 20.0)]; + let c = chamfer(&b, &edges, 2.0).unwrap(); + let m = measure(&c).unwrap(); + assert!(rel(m.volume, 23730.666667) < 1e-6, "{}", m.volume); + assert_eq!(m.merged.faces, 10); +} + +#[test] +#[ignore] +fn debug_chamfer_cut() { + let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap(); + // Prism along x: triangle in the YZ plane beyond the top-front edge. + let plane = Plane { origin: Vec3::new(-5.0, 0.0, 0.0), x: Vec3::Y, y: Vec3::Z }; + let tri = Loop2::polygon(&[Vec2::new(2.3, 20.3), Vec2::new(-0.3, 17.7), Vec2::new(10.0, 30.0)]); + let tri = if tri.signed_area() < 0.0 { tri.reversed() } else { tri }; + let tool = extrude(&plane, &[Region2 { outer: tri, holes: vec![] }], 0.0, 50.0).unwrap().pop().unwrap(); + println!("tool vol {}", measure(&tool).unwrap().volume); + let r = boolean(&b, &tool, BoolOp::Cut).unwrap().unwrap(); + let m = measure(&r).unwrap(); + println!("cut: {} faces {} {:?}", m.volume, m.faces, m.merged); + for f in r.faces(0.01).unwrap() { + println!("face {} area {:.3} c {:?} n {:?}", f.index, f.area, f.centroid, f.plane_normal); + } + for tol in [0.1, 0.01, 0.001] { + println!("tess {tol}: {}", r.tessellate(tol).unwrap().measure().volume); + } +} diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 52a47d2e..809cbf32 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -126,6 +126,36 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce pen.poly(&[(3.0, 8.0), (14.0, 8.0), (14.0, 19.0), (3.0, 19.0)], pen.fill, ink); pen.poly(&[(9.0, 4.0), (21.0, 4.0), (21.0, 15.0), (9.0, 15.0)], pen.fill.gamma_multiply(0.75), a); } + "pattern_rect" => { + for (x, y) in [(4.0, 4.0), (14.0, 4.0), (4.0, 14.0), (14.0, 14.0)] { + pen.poly( + &[(x, y), (x + 6.0, y), (x + 6.0, y + 6.0), (x, y + 6.0)], + if x == 4.0 && y == 4.0 { pen.fill } else { Color32::TRANSPARENT }, + if x == 4.0 && y == 4.0 { ink } else { a }, + ); + } + } + "pattern_circ" => { + for i in 0..6 { + let t = i as f32 / 6.0 * std::f32::consts::TAU; + pen.circle( + 12.0 + 7.5 * t.cos(), + 12.0 + 7.5 * t.sin(), + 2.4, + if i == 0 { pen.fill } else { Color32::TRANSPARENT }, + if i == 0 { ink } else { a }, + ); + } + } + "split" => { + pen.poly(&[(3.0, 6.0), (21.0, 6.0), (21.0, 18.0), (3.0, 18.0)], pen.fill, ink); + pen.line(&[(8.0, 2.0), (16.0, 22.0)], a); + } + "mirror" => { + pen.line(&[(12.0, 2.0), (12.0, 22.0)], ink); + pen.poly(&[(3.0, 7.0), (9.0, 10.0), (9.0, 17.0), (3.0, 17.0)], pen.fill, ink); + pen.poly(&[(21.0, 7.0), (15.0, 10.0), (15.0, 17.0), (21.0, 17.0)], Color32::TRANSPARENT, a); + } "move" => { for (dx, dy) in [(0.0, -1.0), (0.0, 1.0), (-1.0, 0.0), (1.0, 0.0)] { pen.arrow((12.0, 12.0), (12.0 + 9.0 * dx, 12.0 + 9.0 * dy), a); diff --git a/crates/ui-egui/src/timeline.rs b/crates/ui-egui/src/timeline.rs index bc3e64bd..c93c83ad 100644 --- a/crates/ui-egui/src/timeline.rs +++ b/crates/ui-egui/src/timeline.rs @@ -19,6 +19,11 @@ fn icon_of(k: &FeatureKind) -> &'static str { FeatureKind::Sphere { .. } => "sphere", FeatureKind::Torus { .. } => "torus", FeatureKind::Combine { .. } => "combine", + FeatureKind::Pattern { pattern: solvecraft_engine::doc::PatternKind::Rectangular { .. }, .. } => "pattern_rect", + FeatureKind::Pattern { .. } => "pattern_circ", + FeatureKind::Mirror { .. } => "mirror", + FeatureKind::ConstructionPlane { .. } => "plane", + FeatureKind::Split { .. } => "split", FeatureKind::Move { .. } => "move", } }