From 2eb292dd4af76cb695cb214a12fbf0172e526ef9 Mon Sep 17 00:00:00 2001 From: Irakli Kourtis Date: Tue, 13 Oct 2026 20:30:41 -0400 Subject: [PATCH] Surface Revolve: sheets turned from open curves and profiles (#824) Surface Revolve turns open curves and closed profile boundaries about a sketch axis into surface bodies, with cone apexes and sphere poles where a chain ends on the axis, and a closed shell made solid. Adds the SurfaceRevolve feature (format 18 note), the surface.revolve command, a dialog with Edit Feature support and analytic kernel and engine tests. Part of #757. --- crates/doc/src/assembly.rs | 4 +- crates/doc/src/clipboard.rs | 1 + crates/doc/src/document.rs | 30 +++++- crates/doc/src/eval.rs | 3 +- crates/doc/src/eval_surface.rs | 47 +++++++-- crates/doc/src/format.rs | 2 + crates/doc/src/params.rs | 2 +- crates/engine/src/cmd/features.rs | 29 +++--- crates/engine/src/cmd/surface.rs | 85 ++++++++++++++-- crates/kernel/src/lib.rs | 4 +- crates/kernel/src/surfaces.rs | 110 +++++++++++++++++++- crates/ui-egui/src/dialogs.rs | 2 +- crates/ui-egui/src/dialogs_surface.rs | 140 +++++++++++++++++++------- crates/ui-egui/src/preselect_tests.rs | 28 ++++++ crates/ui-egui/src/timeline.rs | 1 + docs/features.md | 6 +- 16 files changed, 411 insertions(+), 83 deletions(-) diff --git a/crates/doc/src/assembly.rs b/crates/doc/src/assembly.rs index 5dade15f..9422f593 100644 --- a/crates/doc/src/assembly.rs +++ b/crates/doc/src/assembly.rs @@ -221,7 +221,8 @@ impl FeatureKind { pt(&mut t.point); } } - FeatureKind::Revolve { axis: AxisRef::Line { origin, dir: d }, .. } => { + FeatureKind::Revolve { axis: AxisRef::Line { origin, dir: d }, .. } + | FeatureKind::SurfaceRevolve { axis: AxisRef::Line { origin, dir: d }, .. } => { pt(origin); dir(d); } @@ -322,6 +323,7 @@ impl FeatureKind { FeatureKind::Revolve { .. } | FeatureKind::Extrude { .. } | FeatureKind::SurfaceExtrude { .. } + | FeatureKind::SurfaceRevolve { .. } | FeatureKind::Combine { .. } | FeatureKind::Loft { .. } | FeatureKind::Sweep { .. } diff --git a/crates/doc/src/clipboard.rs b/crates/doc/src/clipboard.rs index 0add07c0..d7991d78 100644 --- a/crates/doc/src/clipboard.rs +++ b/crates/doc/src/clipboard.rs @@ -48,6 +48,7 @@ impl FeatureKind { } FeatureKind::Extrude { sketch, .. } | FeatureKind::SurfaceExtrude { sketch, .. } + | FeatureKind::SurfaceRevolve { sketch, .. } | FeatureKind::Revolve { sketch, .. } | FeatureKind::Emboss { sketch, .. } | FeatureKind::SheetBase { sketch, .. } diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 2428af3d..ef19528c 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -954,6 +954,23 @@ pub enum FeatureKind { #[serde(default, skip_serializing_if = "Vec::is_empty")] faces: Vec, }, + /// A surface body turned about an axis from open curve chains and the boundaries of + /// profiles: walls only, no caps (a solid where a full turn closes them). + SurfaceRevolve { + sketch: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + profiles: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + curves: Vec, + axis: AxisRef, + angle: String, + /// Two Sides: the angle on the other side. + #[serde(default, skip_serializing_if = "Option::is_none")] + angle2: Option, + /// Symmetric: `angle` split evenly either side of the sketch. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + symmetric: bool, + }, /// A planar surface body extended outward by `distance` past the edges through `edges`. SurfaceExtend { body: String, @@ -1255,6 +1272,7 @@ impl FeatureKind { match self { K::Extrude { sketch, .. } | K::SurfaceExtrude { sketch, .. } + | K::SurfaceRevolve { sketch, .. } | K::Revolve { sketch, .. } | K::Emboss { sketch, .. } | K::Rib { sketch, .. } @@ -1389,6 +1407,7 @@ impl FeatureKind { FeatureKind::Remove { .. } => "RemoveFeature", FeatureKind::Patch { .. } => "PatchFeature", FeatureKind::SurfaceExtrude { .. } => "SurfaceExtrudeFeature", + FeatureKind::SurfaceRevolve { .. } => "SurfaceRevolveFeature", FeatureKind::Stitch { .. } => "StitchFeature", FeatureKind::Thicken { .. } => "ThickenFeature", FeatureKind::SurfaceTrim { .. } => "TrimFeature", @@ -1458,6 +1477,7 @@ impl FeatureKind { FeatureKind::Remove { .. } => "Remove", FeatureKind::Patch { .. } => "Patch", FeatureKind::SurfaceExtrude { .. } => "SurfaceExtrude", + FeatureKind::SurfaceRevolve { .. } => "SurfaceRevolve", FeatureKind::Stitch { .. } => "Stitch", FeatureKind::Thicken { .. } => "Thicken", FeatureKind::SurfaceTrim { .. } => "Trim", @@ -1480,7 +1500,9 @@ impl FeatureKind { | 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::Patch { sketch: Some(sk), profiles, .. } + | FeatureKind::SurfaceExtrude { sketch: sk, profiles: Some(profiles), .. } + | FeatureKind::SurfaceRevolve { 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(), @@ -1496,7 +1518,9 @@ impl FeatureKind { | 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::Patch { sketch: Some(sketch), profiles, .. } + | FeatureKind::SurfaceExtrude { sketch, profiles: Some(profiles), .. } + | FeatureKind::SurfaceRevolve { sketch, profiles: Some(profiles), .. } => { vec![(*sketch, profiles)] } FeatureKind::Loft { sections, .. } => { @@ -1561,7 +1585,7 @@ impl FeatureKind { v.push(d); } } - FeatureKind::Revolve { angle, angle2, .. } => { + FeatureKind::Revolve { angle, angle2, .. } | FeatureKind::SurfaceRevolve { angle, angle2, .. } => { v.push(angle); v.extend(angle2.iter().map(String::as_str)); } diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 9e5d7bb4..2edda63d 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -3634,7 +3634,8 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: | FeatureKind::SurfaceExtend { .. } | FeatureKind::SurfaceExtrude { .. } | FeatureKind::Unstitch { .. } - | FeatureKind::ReverseNormal { .. } => surface::eval(doc, vals, f, st), + | FeatureKind::ReverseNormal { .. } + | FeatureKind::SurfaceRevolve { .. } => surface::eval(doc, vals, f, st), FeatureKind::Emboss { .. } | FeatureKind::Rib { .. } | FeatureKind::ReplaceFace { .. } diff --git a/crates/doc/src/eval_surface.rs b/crates/doc/src/eval_surface.rs index 71541f3c..c612c294 100644 --- a/crates/doc/src/eval_surface.rs +++ b/crates/doc/src/eval_surface.rs @@ -98,17 +98,28 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap, f: &Feature, let d = val(vals, &extent.distance, Kind::Length)?; let off = extent.start_offset.as_deref().map(|e| val(vals, e, Kind::Length)).transpose()?.unwrap_or(0.0); let (a, b) = extent_sides(vals, extent, d, extent.direction)?; - let mut chains: Vec> = Vec::new(); - if let Some(sel) = profiles { - for r in solidcraft_sketch::merge_regions(&select_profiles(&ss, sel)?) { - chains.extend(std::iter::once(r.outer).chain(r.holes).map(|lp| lp.segs)); + let body = kernel::extrude_sheet(&ss.plane, &sheet_chains(&ss, profiles.as_ref(), curves)?, a + off, b + off)?; + let name = new_name(st, f, 0); + st.bodies.push(ModelBody::new(name, body, f.id)); + Ok(()) + } + FeatureKind::SurfaceRevolve { sketch, profiles, curves, axis, angle, angle2, symmetric } => { + let ss = sketch_in_feature_frame( + doc, + st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?, + f.component, + )?; + let (o, d) = revolve_axis(&ss, axis)?; + let a = val(vals, angle, Kind::Angle)?; + let (start, sweep) = match (angle2, symmetric) { + (Some(a2), _) => { + let a2 = val(vals, a2, Kind::Angle)?; + (-a2, a + a2) } - } - chains.extend(curve_chains(&ss, curves)?); - if chains.is_empty() { - return Err(DocError::Invalid("pick the profiles or curves to extrude".into())); - } - let body = kernel::extrude_sheet(&ss.plane, &chains, a + off, b + off)?; + (None, true) => (-a / 2.0, a), + (None, false) => (0.0, a), + }; + let body = kernel::revolve_sheet(&ss.plane, &sheet_chains(&ss, profiles.as_ref(), curves)?, o, d, start, sweep)?; let name = new_name(st, f, 0); st.bodies.push(ModelBody::new(name, body, f.id)); Ok(()) @@ -148,6 +159,22 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap, f: &Feature, } } +/// What a surface builder sweeps: the boundary loops of the picked profiles, then the picked +/// curves chained end to end. +fn sheet_chains(ss: &SolvedSketch, profiles: Option<&ProfileSel>, curves: &[String]) -> Result>> { + let mut chains: Vec> = Vec::new(); + if let Some(sel) = profiles { + for r in solidcraft_sketch::merge_regions(&select_profiles(ss, sel)?) { + chains.extend(std::iter::once(r.outer).chain(r.holes).map(|lp| lp.segs)); + } + } + chains.extend(curve_chains(ss, curves)?); + if chains.is_empty() { + return Err(DocError::Invalid("pick the profiles or curves".into())); + } + Ok(chains) +} + /// Most sketch curves one feature chains. const MAX_CHAIN_CURVES: usize = 1000; diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs index f251f5f1..67eed4b1 100644 --- a/crates/doc/src/format.rs +++ b/crates/doc/src/format.rs @@ -116,6 +116,8 @@ //! Under the same version, Delete Face can leave the gap open (`heal: false` on the feature: //! the faces left are a surface body); a Delete Face without it heals, as it always did, and //! saves as before. 0.9.0 would heal a gap the design leaves open. +//! A `SurfaceRevolve` feature (Surface Revolve: `sketch`, optional `profiles`, `curves`, +//! `axis`, `angle`, optional `angle2` and `symmetric`) is also first saved in 18. use serde_json::Value; diff --git a/crates/doc/src/params.rs b/crates/doc/src/params.rs index 3b51e6e2..de802a9b 100644 --- a/crates/doc/src/params.rs +++ b/crates/doc/src/params.rs @@ -69,7 +69,7 @@ impl FeatureKind { v.push(("To offset", d, L)); } } - FeatureKind::Revolve { angle, angle2, .. } => { + FeatureKind::Revolve { angle, angle2, .. } | FeatureKind::SurfaceRevolve { angle, angle2, .. } => { v.push(("Angle", angle, A)); if let Some(a) = angle2 { v.push(("Angle 2", a, A)); diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 11042c8b..8818e566 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -576,18 +576,7 @@ fn revolve(s: &mut Session, p: &Value) -> Result { } None => (feature_sketch(s, p, cmd)?, None), }; - let axis = if let Some(o) = p.get("axis").filter(|v| v.is_object()) { - let origin = o.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO); - let dir = o.get("dir").and_then(vec3).ok_or_else(|| bad(cmd, "axis needs `dir`"))?; - AxisRef::Line { origin, dir } - } else { - match str_(p, "axis") { - Some(a @ ("X" | "Y" | "Z")) => AxisRef::World { axis: a.into() }, - Some(a @ ("x" | "y")) => AxisRef::SketchAxis { axis: a.into() }, - Some(id) => AxisRef::SketchLine { curve: id.into() }, - None => return Err(bad(cmd, "`axis` must be a sketch line id, x, y (sketch axes), X, Y, Z or {origin, dir}")), - } - }; + let axis = axis_param(p, cmd)?; let angle = expr(p, "angle").unwrap_or_else(|| "360 deg".into()); check_expr(s, &angle, Kind::Angle, cmd, "angle")?; let profiles = match face_profile { @@ -623,6 +612,22 @@ fn revolve(s: &mut Session, p: &Value) -> Result { add_feature(s, p, kind) } +/// A revolve axis from its parameter: a sketch line id, the sketch's x or y axis, a world axis, +/// or {origin, dir}. +pub(super) fn axis_param(p: &Value, cmd: &str) -> Result { + if let Some(o) = p.get("axis").filter(|v| v.is_object()) { + let origin = o.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO); + let dir = o.get("dir").and_then(vec3).ok_or_else(|| bad(cmd, "axis needs `dir`"))?; + return Ok(AxisRef::Line { origin, dir }); + } + match str_(p, "axis") { + Some(a @ ("X" | "Y" | "Z")) => Ok(AxisRef::World { axis: a.into() }), + Some(a @ ("x" | "y")) => Ok(AxisRef::SketchAxis { axis: a.into() }), + Some(id) => Ok(AxisRef::SketchLine { curve: id.into() }), + None => Err(bad(cmd, "`axis` must be a sketch line id, x, y (sketch axes), X, Y, Z or {origin, dir}")), + } +} + fn radius_expr(s: &Session, p: &Value, cmd: &str) -> Result { match (expr(p, "radius"), expr(p, "diameter")) { (Some(r), _) => Ok(r), diff --git a/crates/engine/src/cmd/surface.rs b/crates/engine/src/cmd/surface.rs index 9bb83691..5fc3f42b 100644 --- a/crates/engine/src/cmd/surface.rs +++ b/crates/engine/src/cmd/surface.rs @@ -5,7 +5,7 @@ use serde_json::Value; use solidcraft_doc::{FaceAt, FeatureKind, PlaneRef, ProfileSel, expr::Kind}; use super::CommandSpec; -use super::features::{add_feature, check_expr, extent_param, feature_sketch, operation, plane_param, profiles}; +use super::features::{add_feature, axis_param, check_expr, extent_param, feature_sketch, operation, plane_param, profiles}; use crate::params::{bad, bool_, expr, str_, string_list, vec3}; use crate::{Result, Session}; @@ -34,10 +34,10 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SURFACE", "CREATE") .icon("surface_extrude") .params("sketch?: id|name; curves?: [curve ids] (open or closed chains of curves meeting end to end) and/or profiles? (their boundaries; the default when no curves are given); distance: expr; direction?: positive|negative|symmetric; distance2?: expr; start_offset?: expr; name?, body_name? (a surface body: walls only, no caps)"), - planned!("surface.revolve", "Surface Revolve", "757") + CommandSpec::new("surface.revolve", "Surface Revolve", surface_revolve) .at("SURFACE", "CREATE") .icon("surface_revolve") - .params("planned: the parameters of solid.revolve, with open curve chains as well as profiles (no caps); caps?: bool"), + .params("sketch?: id|name; curves?: [curve ids] and/or profiles? (as surface.extrude); axis: sketch line id|x|y|X|Y|Z|{origin, dir}; angle?: expr (default 360 deg); angle2?: expr (the other side); symmetric?: bool; name?, body_name? (a surface body, no caps; a solid where a full turn closes it)"), planned!("surface.sweep", "Surface Sweep", "757") .at("SURFACE", "CREATE") .icon("surface_sweep") @@ -98,11 +98,7 @@ fn patch(s: &mut Session, p: &Value) -> Result { fn surface_extrude(s: &mut Session, p: &Value) -> Result { let cmd = "surface.extrude"; let sketch = feature_sketch(s, p, cmd)?; - let curves = string_list(p, "curves"); - if curves.len() > 1000 { - return Err(bad(cmd, "at most 1000 curves")); - } - let profiles = if curves.is_empty() || p.get("profiles").is_some() { Some(profiles(p, cmd)?) } else { None }; + let (profiles, curves) = sheet_inputs(p, cmd)?; let extent = extent_param(s, p, cmd)?; if extent.through_all || extent.taper.is_some() || extent.to.is_some() || extent.from.is_some() { return Err(bad(cmd, "not supported yet: through all, to or from an object, or a taper")); @@ -133,6 +129,32 @@ fn reverse_normal(s: &mut Session, p: &Value) -> Result { add_feature(s, p, FeatureKind::ReverseNormal { body, faces }) } +/// The profiles and curves a surface builder takes: `curves`, and `profiles` (all of them when +/// neither is given). +fn sheet_inputs(p: &Value, cmd: &str) -> Result<(Option, Vec)> { + let curves = string_list(p, "curves"); + if curves.len() > 1000 { + return Err(bad(cmd, "at most 1000 curves")); + } + let profiles = if curves.is_empty() || p.get("profiles").is_some() { Some(profiles(p, cmd)?) } else { None }; + Ok((profiles, curves)) +} + +fn surface_revolve(s: &mut Session, p: &Value) -> Result { + let cmd = "surface.revolve"; + let sketch = feature_sketch(s, p, cmd)?; + let (profiles, curves) = sheet_inputs(p, cmd)?; + let axis = axis_param(p, cmd)?; + let angle = expr(p, "angle").unwrap_or_else(|| "360 deg".into()); + check_expr(s, &angle, Kind::Angle, cmd, "angle")?; + let angle2 = expr(p, "angle2"); + if let Some(a) = &angle2 { + check_expr(s, a, Kind::Angle, cmd, "angle2")?; + } + let symmetric = bool_(p, "symmetric").unwrap_or(false) || str_(p, "direction").is_some_and(|d| d.eq_ignore_ascii_case("symmetric")); + add_feature(s, p, FeatureKind::SurfaceRevolve { sketch, profiles, curves, axis, angle, angle2, symmetric }) +} + fn bodies_param(s: &Session, p: &Value, cmd: &str) -> Result> { let bodies = string_list(p, "bodies"); if bodies.is_empty() { @@ -364,13 +386,58 @@ mod tests { assert!(s.execute("face.delete", &json!({"faces": all, "body": "Healed", "heal": false})).is_err()); } + /// Surface Revolve of open curves about the sketch's y axis: a line beside the axis makes a + /// cylinder (all of it, a quarter, a symmetric quarter), a line from the axis a cone, a half + /// circle a solid ball; a profile turned all the way round is the solid solid.revolve makes. + #[test] + fn surface_revolve_curves_and_profiles() { + use std::f64::consts::PI; + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"})); + run(&mut s, "sketch.line", json!({"points": [[3, 0], [3, 4]], "ids": ["side"]})); + run(&mut s, "sketch.line", json!({"points": [[0, 10], [3, 14]], "ids": ["slant"]})); + run(&mut s, "sketch.finish", json!({})); + let close = |a: f64, want: f64| assert!((a - want).abs() < want * 2e-3, "{a} vs {want}"); // (measured on meshes) + run(&mut s, "surface.revolve", json!({"sketch": "S", "curves": ["side"], "axis": "y", "body_name": "Tube"})); + close(area(&s, "Tube"), 2.0 * PI * 3.0 * 4.0); + run(&mut s, "surface.revolve", json!({"sketch": "S", "curves": ["side"], "axis": "Y", "angle": "90 deg", "body_name": "Quarter"})); + close(area(&s, "Quarter"), PI / 2.0 * 3.0 * 4.0); + run( + &mut s, + "surface.revolve", + json!({"sketch": "S", "curves": ["side"], "axis": "y", "angle": "90 deg", "symmetric": true, "body_name": "Both"}), + ); + let bb = solidcraft_kernel::measure(&s.world_state().body("Both").unwrap().body).unwrap().bbox; + let z = 3.0 * (PI / 4.0).sin(); + assert!((bb.min.z + z).abs() < 1e-3 && (bb.max.z - z).abs() < 1e-3, "{bb:?}"); + run(&mut s, "surface.revolve", json!({"sketch": "S", "curves": ["slant"], "axis": "y", "body_name": "Cone"})); + close(area(&s, "Cone"), PI * 3.0 * 5.0); + assert!(s.world_state().body("Cone").unwrap().body.is_surface()); + run(&mut s, "edit.undo", json!({})); + assert!(s.world_state().body("Cone").is_none()); + run(&mut s, "edit.redo", json!({})); + let cold = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + assert!((area(&cold, "Cone") - area(&s, "Cone")).abs() < 1e-9); + // A rectangle off the axis turned all the way round closes into the ring solid.revolve makes. + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "R"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [5, 0], "p1": [7, 3]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "surface.revolve", json!({"sketch": "R", "axis": "y", "body_name": "Ring"})); + run(&mut s, "solid.revolve", json!({"sketch": "R", "axis": "y", "body_name": "Solid"})); + assert!(!s.world_state().body("Ring").unwrap().body.is_surface()); + close(volume(&s, "Ring"), volume(&s, "Solid")); + // Refused: a curve across the axis, no axis, and a turn of more than 360°. + assert!(s.execute("surface.revolve", &json!({"sketch": "S", "curves": ["side"], "axis": {"origin": [3, 2, 0], "dir": [1, 0, 0]}})).is_err()); + assert!(s.execute("surface.revolve", &json!({"sketch": "S", "curves": ["side"]})).is_err()); + assert!(s.execute("surface.revolve", &json!({"sketch": "S", "curves": ["side"], "axis": "y", "angle": "400 deg"})).is_err()); + } + /// The planned surface commands are registered but refuse to run: disabled with the /// issue that tracks them, an error (never a panic) from every caller, and the design left /// as it was. #[test] fn planned_commands_refuse_and_leave_the_design_alone() { let planned = [ - "surface.revolve", "surface.sweep", "surface.loft", "surface.offset", diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 5d07a10a..83d5bc0e 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -113,7 +113,9 @@ pub use step::step_export; pub use step_in::{ImportedBody, StepImport, StepNode, step_import, step_import_shared, step_orientation_errors, step_validate}; pub use step_out::{ExportBody, ExportProduct, StepHeader, step_export_bodies, step_export_products}; pub use surface_kind::{FaceSurface, face_surface}; -pub use surfaces::{copy_faces, extend, extrude_sheet, patch_edges, patch_region, remove_faces, reverse_faces, stitch, thicken, trim, unstitch}; +pub use surfaces::{ + copy_faces, extend, extrude_sheet, patch_edges, patch_region, remove_faces, reverse_faces, revolve_sheet, stitch, thicken, trim, unstitch, +}; pub use sweep_path::sweep_path; pub use topo::{CylinderFace, cylinder_face_at}; pub use topo::{TopoCounts, merged_topology, seam_flags}; diff --git a/crates/kernel/src/surfaces.rs b/crates/kernel/src/surfaces.rs index 4cf04708..63065372 100644 --- a/crates/kernel/src/surfaces.rs +++ b/crates/kernel/src/surfaces.rs @@ -3,12 +3,12 @@ //! planar sketch region), Stitch (surfaces sharing edges sewn //! into one body, a solid when they close), Trim (a surface cut by a tool, one side kept), //! Thicken (a surface offset along its normals into a solid), copying faces out of a body, and -//! sheets swept from sketch curves (surface extrude). +//! sheets swept from sketch curves (surface extrude and revolve). use std::collections::HashMap; use mt::{BoundedCurve, InnerSpace, MetricSpace, ParametricCurve, ParametricSurface3D, SearchNearestParameter}; -use solidcraft_geom::{Plane, Region2, Seg2, Vec3}; +use solidcraft_geom::{Plane, Region2, Seg2, Vec2, Vec3}; use truck_modeling as mt; use crate::body::{Body, from_p3, p3}; @@ -100,6 +100,68 @@ pub fn extrude_sheet(plane: &Plane, chains: &[Vec], start: f64, end: f64) Ok(rebuilt_body(surface_body(faces), &[], &rec)) } +/// A surface revolved from chains of sketch segments on `plane` about the sketch line through +/// `axis_origin` along `axis_dir`: each chain turned from `start` through `sweep` radians (a +/// full turn at most), positive angles carrying it toward the sketch normal. A chain end on the +/// axis stays put there (a cone's apex, a sphere's pole). The walls form one surface body with +/// no caps, or a solid where they close (a full turn of a closed curve, or of a curve from the +/// axis back to it); its history has no operands: every face is new. +pub fn revolve_sheet(plane: &Plane, chains: &[Vec], axis_origin: Vec2, axis_dir: Vec2, start: f64, sweep: f64) -> Result { + use std::f64::consts::TAU; + if !crate::build::frame_ok(plane) || !axis_origin.is_finite() || !axis_dir.is_finite() { + return Err(KernelError::Invalid("revolve frame is not finite and orthonormal".into())); + } + if !(start.is_finite() && sweep.is_finite() && start.abs() <= TAU && sweep.abs() <= TAU + 1e-9) { + return Err(KernelError::Invalid("revolve angles must be finite, a full turn at most".into())); + } + if sweep.abs() < 1e-6 { + return Err(KernelError::Invalid("revolve angle is zero".into())); + } + if chains.is_empty() || chains.len() > MAX_CHAINS { + return Err(KernelError::Invalid("pick the curves or profiles to revolve".into())); + } + let d = axis_dir.normalized().ok_or_else(|| KernelError::Invalid("revolve axis has no direction".into()))?; + let side = |p: Vec2| d.cross(p - axis_origin); + let on_axis = |p: Vec2| side(p).abs() < 1e-7; + let (origin, n) = (plane.to_world(axis_origin), plane.normal()); + let axis = plane.dir_to_world(d).normalized().ok_or_else(|| KernelError::Invalid("revolve frame has no axis".into()))?; + let mut rec = Rebuild::default(); + let mut faces = Vec::new(); + for chain in chains { + // On one side of the axis; the point farthest from it fixes the turning sense. + let pts: Vec = chain.iter().flat_map(|s| s.polyline(1e-3)).collect(); + if pts.iter().any(|p| side(*p) < -1e-7) && pts.iter().any(|p| side(*p) > 1e-7) { + return Err(KernelError::Invalid("the curves cross the revolve axis".into())); + } + let far = pts.iter().copied().max_by(|a, b| side(*a).abs().total_cmp(&side(*b).abs())).filter(|p| !on_axis(*p)); + let Some(far) = far else { return Err(KernelError::Invalid("the curves lie on the revolve axis".into())) }; + let radial = plane.to_world(far) - origin; + let a = if axis.cross(radial).dot(n) < 0.0 { -axis } else { axis }; + let (first, last) = match (chain.first(), chain.last()) { + (Some(f), Some(l)) => (f.start(), l.end()), + _ => return Err(KernelError::Invalid("an empty curve chain".into())), + }; + let closed = first.dist(last) <= 1e-6; + if closed && pts.iter().any(|p| on_axis(*p)) { + return Err(KernelError::Invalid("not supported yet: a closed curve touching the revolve axis".into())); + } + let wire = crate::build::chain_wire(plane, chain)?; + let walls: mt::Shell = guard("surface revolve", || { + let wire = mt::builder::rotated(&wire, p3(origin), crate::body::v3(a), mt::Rad(start)); + Ok(match (on_axis(first), on_axis(last)) { + (true, _) => mt::builder::cone(&wire, crate::body::v3(a), mt::Rad(sweep)), + (false, true) => mt::builder::cone(&wire.inverse(), crate::body::v3(a), mt::Rad(sweep)), + (false, false) => mt::builder::rsweep(&wire, p3(origin), crate::body::v3(a), mt::Rad(sweep)), + }) + })?; + for f in walls.face_iter() { + rec.made(f, Vec::new()); + faces.push(f.clone()); + } + } + body_of(faces, &[], &rec) +} + /// The edge of a body nearest a point (by samples along each edge). pub(crate) fn edge_near(body: &Body, p: Vec3) -> Option { let mut best: Option<(f64, mt::Edge)> = None; @@ -773,6 +835,50 @@ mod tests { assert!(rel(measure(&closed).unwrap().volume, 400.0) < 1e-9); } + /// Sheets revolved about the sketch's y axis, against their analytic areas: a cylinder (and a + /// quarter of one, started a quarter turn round), cones with the apex at either end, a sphere + /// from a half circle, and a square's ring (Pappus: 2π × centroid distance × length). + #[test] + fn revolve_sheet_areas() { + use std::f64::consts::{FRAC_PI_2, PI}; + let sheet = |chain: Vec, start: f64, sweep: f64| { + revolve_sheet(&Plane::XY, &[chain], Vec2::new(0.0, 0.0), Vec2::new(0.0, 1.0), start, sweep).unwrap() + }; + let rev = |chain: Vec, start: f64, sweep: f64| measure(&sheet(chain, start, sweep)).unwrap().area; + let line = |a: (f64, f64), b: (f64, f64)| Seg2::Line { a: Vec2::new(a.0, a.1), b: Vec2::new(b.0, b.1) }; + let close = |a: f64, want: f64| assert!(rel(a, want) < 2e-3, "{a} vs {want}"); // (measured on meshes) + close(rev(vec![line((3.0, 0.0), (3.0, 4.0))], 0.0, 2.0 * PI), 2.0 * PI * 3.0 * 4.0); + close(rev(vec![line((3.0, 0.0), (3.0, 4.0))], FRAC_PI_2, FRAC_PI_2), FRAC_PI_2 * 3.0 * 4.0); + close(rev(vec![line((0.0, 0.0), (3.0, 4.0))], 0.0, 2.0 * PI), PI * 3.0 * 5.0); + close(rev(vec![line((3.0, 4.0), (0.0, 0.0))], 0.0, 2.0 * PI), PI * 3.0 * 5.0); + assert!(sheet(vec![line((3.0, 0.0), (3.0, 4.0))], 0.0, 1.0).is_surface()); + // Closing shells are solids, outward: a sphere from a half circle, a square's ring. + let half = Seg2::Arc { center: Vec2::new(0.0, 0.0), radius: 2.0, start: -FRAC_PI_2, sweep: PI }; + let ball = measure(&sheet(vec![half], 0.0, 2.0 * PI)).unwrap(); + close(ball.area, 4.0 * PI * 4.0); + close(ball.volume, 4.0 / 3.0 * PI * 8.0); + assert!(sheet(vec![half], 0.0, PI).is_surface()); + let sq = Loop2::polygon(&[Vec2::new(2.0, 0.0), Vec2::new(3.0, 0.0), Vec2::new(3.0, 1.0), Vec2::new(2.0, 1.0)]); + let ring = measure(&sheet(sq.segs.clone(), 0.0, 2.0 * PI)).unwrap(); + close(ring.area, 2.0 * PI * (2.0 + 3.0 + 2.5 + 2.5)); + close(ring.volume, 2.0 * PI * 2.5); + } + + #[test] + fn revolve_sheet_refuses_bad_input() { + let rev = |chain: Vec, sweep: f64| revolve_sheet(&Plane::XY, &[chain], Vec2::new(0.0, 0.0), Vec2::new(0.0, 1.0), 0.0, sweep); + let line = |a: (f64, f64), b: (f64, f64)| Seg2::Line { a: Vec2::new(a.0, a.1), b: Vec2::new(b.0, b.1) }; + assert!(rev(vec![line((-1.0, 0.0), (1.0, 1.0))], 1.0).is_err(), "crosses the axis"); + assert!(rev(vec![line((0.0, 0.0), (0.0, 1.0))], 1.0).is_err(), "on the axis"); + assert!(rev(vec![line((1.0, 0.0), (1.0, 1.0))], 0.0).is_err(), "zero angle"); + assert!(rev(vec![line((1.0, 0.0), (1.0, 1.0))], 7.0).is_err(), "more than a turn"); + assert!(rev(vec![line((1.0, 0.0), (1.0, 1.0))], f64::NAN).is_err()); + let touching = Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(1.0, 0.0), Vec2::new(1.0, 1.0), Vec2::new(0.0, 1.0)]); + assert!(rev(touching.segs, 1.0).is_err(), "a closed curve touching the axis"); + assert!(revolve_sheet(&Plane::XY, &[], Vec2::new(0.0, 0.0), Vec2::new(0.0, 1.0), 0.0, 1.0).is_err()); + assert!(revolve_sheet(&Plane::XY, &[vec![line((1.0, 0.0), (1.0, 1.0))]], Vec2::new(0.0, 0.0), Vec2::new(0.0, 0.0), 0.0, 1.0).is_err()); + } + #[test] fn extrude_sheet_refuses_bad_input() { let line = vec![Seg2::Line { a: Vec2::new(0.0, 0.0), b: Vec2::new(10.0, 0.0) }]; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 99956e0e..e1b3b0b4 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4417,7 +4417,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option { let (kind, inputs, extra) = crate::dialogs_sheet::for_feature(app, other) .or_else(|| crate::dialogs_plastic::for_feature(app, other)) - .or_else(|| crate::dialogs_surface::for_feature(app, other))?; + .or_else(|| crate::dialogs_surface::for_feature(app, other, &feature_frame(s, f.component)))?; let mut d = Dialog::new(kind, inputs); d.extra = extra; d diff --git a/crates/ui-egui/src/dialogs_surface.rs b/crates/ui-egui/src/dialogs_surface.rs index 8cd348fa..4abd9284 100644 --- a/crates/ui-egui/src/dialogs_surface.rs +++ b/crates/ui-egui/src/dialogs_surface.rs @@ -1,15 +1,15 @@ -//! Dialogs for the surface tools and face splitting (#19): Surface Extrude, Patch, Stitch, -//! Thicken, Extend, Trim and Split Face. Each has a live preview and Edit Feature. +//! Dialogs for the surface tools and face splitting (#19): Surface Extrude and Revolve, Patch, +//! Stitch, Thicken, Extend, Trim and Split Face. Each has a live preview and Edit Feature. use serde_json::{Map, Value, json}; use solidcraft_engine::Sel; use solidcraft_engine::doc::expr::Kind as ValueKind; -use solidcraft_engine::doc::{Direction, FaceAt, FeatureKind, PlaneRef, ProfileSel}; +use solidcraft_engine::doc::{AxisRef, Direction, FaceAt, FeatureKind, Mat, PlaneRef, ProfileSel}; use solidcraft_engine::geom::Vec3; use crate::SolveApp; use crate::dialogs::{Kind, OP_LABELS, OPS, combo, field, op_index, row_label}; -use crate::selection::{BODIES, CURVES, EDGES, FACES, PLANES, PROFILES, SelInput}; +use crate::selection::{AXES, BODIES, CURVES, EDGES, FACES, PLANES, PROFILES, SelInput}; /// A surface tool dialog's values. #[derive(Clone, Debug, PartialEq)] @@ -19,6 +19,12 @@ pub enum Surf { distance: String, direction: usize, }, + /// `axis`: a stored {origin, dir} axis, used while no axis is picked. + Revolve { + angle: String, + symmetric: bool, + axis: Option, + }, Patch, Unstitch, ReverseNormal, @@ -44,6 +50,7 @@ impl Surf { pub fn title(&self) -> &'static str { match self { Surf::Extrude { .. } => "SURFACE EXTRUDE", + Surf::Revolve { .. } => "SURFACE REVOLVE", Surf::Patch => "PATCH", Surf::Unstitch => "UNSTITCH", Surf::ReverseNormal => "REVERSE NORMAL", @@ -61,6 +68,7 @@ impl Surf { Some(match self { Surf::Thicken { thickness, .. } => ("Thickness", ValueKind::Length, thickness), Surf::Extend { distance } | Surf::Extrude { distance, .. } => ("Distance", ValueKind::Length, distance), + Surf::Revolve { angle, .. } => ("Angle", ValueKind::Angle, angle), _ => return None, }) } @@ -72,6 +80,7 @@ const EXTRUDE_SIDES: [(&str, &str); 3] = [("One Side", "positive"), ("Symmetric" fn inputs(k: &Surf) -> Vec { match k { Surf::Extrude { .. } => vec![SelInput::new("Profiles or curves", PROFILES | CURVES, true)], + Surf::Revolve { .. } => vec![SelInput::new("Profiles or curves", PROFILES | CURVES, true), SelInput::new("Axis", AXES, false)], Surf::Patch => vec![SelInput::new("Boundary", PROFILES | EDGES, true)], Surf::Unstitch => vec![SelInput::new("Body, or faces to take out", BODIES | FACES, true)], Surf::ReverseNormal => vec![SelInput::new("Surface, or faces to turn over", BODIES | FACES, true)], @@ -92,6 +101,7 @@ fn inputs(k: &Surf) -> Vec { pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { let k = match id { "surface.extrude" => Surf::Extrude { distance: "10 mm".into(), direction: 0 }, + "surface.revolve" => Surf::Revolve { angle: "360 deg".into(), symmetric: false, axis: None }, "surface.patch" => Surf::Patch, "surface.unstitch" => Surf::Unstitch, "surface.reverse_normal" => Surf::ReverseNormal, @@ -136,6 +146,16 @@ pub fn rows(ui: &mut egui::Ui, k: &mut Surf) -> bool { enter |= field(ui, distance); ui.end_row(); } + Surf::Revolve { angle, symmetric, .. } => { + row_label(ui, "Angle"); + enter |= field(ui, angle); + ui.end_row(); + row_label(ui, "Direction"); + let mut dir = usize::from(*symmetric); + combo(ui, "sr_dir", &["One Side", "Symmetric"], &mut dir); + *symmetric = dir == 1; + ui.end_row(); + } Surf::Extrude { distance, direction } => { row_label(ui, "Distance"); enter |= field(ui, distance); @@ -175,6 +195,39 @@ fn body_and_faces(picks: &[Sel]) -> Result<(String, Vec), String> { Ok((body, faces)) } +/// The sketch, profiles and curves of a surface builder's picks (all in one sketch). +fn sheet_picks(app: &SolveApp, picks: &[Sel]) -> Result { + let profiles: Vec<(u64, usize)> = + picks.iter().filter_map(|x| if let Sel::Profile { sketch, index } = x { Some((*sketch, *index)) } else { None }).collect(); + let curves: Vec = picks.iter().filter_map(|x| if let Sel::SketchCurve { id } = x { Some(id.clone()) } else { None }).collect(); + let sketch = match profiles.first() { + Some((sk, _)) => *sk, + None => crate::dialogs::curves_sketch(app, &curves, None).ok_or("the curves must be in one sketch")?, + }; + if profiles.iter().any(|(sk, _)| *sk != sketch) { + return Err("the profiles and curves must be in one sketch".into()); + } + let mut p = json!({ "sketch": sketch }); + if !curves.is_empty() { + p["curves"] = json!(curves); + } + if !profiles.is_empty() { + p["profiles"] = json!(profiles.iter().map(|(_, i)| *i).collect::>()); + } + Ok(p) +} + +/// A surface builder's stored picks as selections again: its profiles, then its curves. +fn sheet_sels(app: &SolveApp, sketch: u64, profiles: Option<&ProfileSel>, curves: &[String]) -> Vec { + let st = app.session.world_state(); + let mut sels: Vec = match (profiles, st.sketch(sketch)) { + (Some(sel), Some(ss)) => crate::dialogs::profile_indices(ss, sel).into_iter().map(|index| Sel::Profile { sketch, index }).collect(), + _ => Vec::new(), + }; + sels.extend(curves.iter().map(|id| Sel::SketchCurve { id: id.clone() })); + sels +} + /// A splitting or trimming tool: a plane by name, a planar face as its plane, or any other /// face as {body, point} (its surface extended). fn tool(app: &SolveApp, sel: Option<&Sel>) -> Result<(&'static str, Value), String> { @@ -194,30 +247,25 @@ pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map { need(0, "profiles or curves to extrude")?; - let picks = items(inputs, 0); - let profiles: Vec<(u64, usize)> = - picks.iter().filter_map(|x| if let Sel::Profile { sketch, index } = x { Some((*sketch, *index)) } else { None }).collect(); - let curves: Vec = picks.iter().filter_map(|x| if let Sel::SketchCurve { id } = x { Some(id.clone()) } else { None }).collect(); - let sketch = match profiles.first() { - Some((sk, _)) => *sk, - None => crate::dialogs::curves_sketch(app, &curves, None).ok_or("the curves must be in one sketch")?, - }; - if profiles.iter().any(|(sk, _)| *sk != sketch) { - return Err("the profiles and curves must be in one sketch".into()); - } - let mut p = json!({ - "sketch": sketch, - "distance": distance, - "direction": EXTRUDE_SIDES.get(*direction).map_or("positive", |(_, d)| *d), - }); - if !curves.is_empty() { - p["curves"] = json!(curves); - } - if !profiles.is_empty() { - p["profiles"] = json!(profiles.iter().map(|(_, i)| *i).collect::>()); - } + let mut p = sheet_picks(app, items(inputs, 0))?; + p["distance"] = json!(distance); + p["direction"] = json!(EXTRUDE_SIDES.get(*direction).map_or("positive", |(_, d)| *d)); ("surface.extrude", p) } + Surf::Revolve { angle, symmetric, axis } => { + need(0, "profiles or curves to revolve")?; + let mut p = sheet_picks(app, items(inputs, 0))?; + p["axis"] = match items(inputs, 1).first() { + Some(Sel::Axis { name }) => json!(name), + Some(Sel::SketchCurve { id }) => json!(id), + _ => axis.clone().ok_or("select an axis")?, + }; + p["angle"] = json!(angle); + if *symmetric { + p["symmetric"] = json!(true); + } + ("surface.revolve", p) + } Surf::Patch => { need(0, "a boundary (sketch profiles or a loop of edges)")?; let profiles: Vec<(u64, usize)> = @@ -309,23 +357,37 @@ fn tool_sel(app: &SolveApp, plane: &PlaneRef, tool: Option<&FaceAt>) -> Option Option<(Kind, Vec, Map)> { +/// `frame` maps the feature's component frame to the world, for an axis stored as a line. +pub fn for_feature(app: &SolveApp, kind: &FeatureKind, frame: &Mat) -> Option<(Kind, Vec, Map)> { let s = &app.session; let edge = |p: &Vec3| crate::dialogs::edge_sel(s, *p); let (k, fill): (Surf, Vec>) = match kind { - FeatureKind::SurfaceExtrude { sketch, profiles, curves, extent } => { - let mut sels: Vec = Vec::new(); - let st = s.world_state(); - if let (Some(sel), Some(ss)) = (profiles, st.sketch(*sketch)) { - let all = 0..ss.profiles.len(); - match sel { - ProfileSel::Indices { indices } => { - sels.extend(indices.iter().filter(|i| all.contains(i)).map(|index| Sel::Profile { sketch: *sketch, index: *index })) - } - _ => sels.extend(all.map(|index| Sel::Profile { sketch: *sketch, index })), - } + FeatureKind::SurfaceRevolve { sketch, profiles, curves, axis, angle, angle2, symmetric } => { + let (pick, stored) = match axis { + AxisRef::World { axis } => (Some(Sel::Axis { name: axis.to_ascii_uppercase() }), None), + AxisRef::SketchAxis { axis } => (Some(Sel::Axis { name: axis.to_ascii_lowercase() }), None), + AxisRef::SketchLine { curve } => (Some(Sel::SketchCurve { id: curve.clone() }), None), + AxisRef::Line { origin, dir } => ( + None, + Some(json!({ + "origin": pt(solidcraft_engine::doc::apply_point(frame, *origin)), + "dir": pt(solidcraft_engine::doc::apply_vector(frame, *dir)) + })), + ), + }; + let mut extra = Map::new(); + if let Some(a2) = angle2 { + extra.insert("angle2".into(), json!(a2)); } - sels.extend(curves.iter().map(|id| Sel::SketchCurve { id: id.clone() })); + let k = Surf::Revolve { angle: angle.clone(), symmetric: *symmetric, axis: stored }; + let mut ins = inputs(&k); + for (inp, items) in ins.iter_mut().zip([sheet_sels(app, *sketch, profiles.as_ref(), curves), pick.into_iter().collect()]) { + inp.items = items; + } + return Some((Kind::Surface(k), ins, extra)); + } + FeatureKind::SurfaceExtrude { sketch, profiles, curves, extent } => { + let sels = sheet_sels(app, *sketch, profiles.as_ref(), curves); let direction = match extent.direction { Direction::Positive => 0, Direction::Symmetric => 1, diff --git a/crates/ui-egui/src/preselect_tests.rs b/crates/ui-egui/src/preselect_tests.rs index 7e1a367f..3804f63d 100644 --- a/crates/ui-egui/src/preselect_tests.rs +++ b/crates/ui-egui/src/preselect_tests.rs @@ -854,6 +854,34 @@ fn unstitch_and_reverse_normal_dialogs_take_a_body_or_faces() { app.session.execute(&id, &p).unwrap(); } +#[test] +fn surface_revolve_dialog_takes_curves_and_an_axis() { + // A line beside the sketch's y axis, turned a quarter round: a quarter cylinder. Edit + // Feature brings back the curve and the axis. + let mut app = app_with(json!([ + {"command": "sketch.create", "params": {"plane": "XY"}}, + {"command": "sketch.line", "params": {"points": [[3, 0], [3, 4]], "ids": ["side"]}}, + {"command": "sketch.finish", "params": {}} + ])); + let mut d = start(&mut app, vec![], "surface.revolve"); + d.inputs[0].items = vec![Sel::SketchCurve { id: "side".into() }]; + assert!(apply_commands(&app, &d).is_err(), "no axis yet"); + d.inputs[1].items = vec![Sel::Axis { name: "y".into() }]; + if let Kind::Surface(crate::dialogs_surface::Surf::Revolve { angle, .. }) = &mut d.kind { + *angle = "90 deg".into(); + } + let (id, p) = apply_commands(&app, &d).unwrap().remove(0); + assert_eq!((id.as_str(), &p["axis"], &p["curves"]), ("surface.revolve", &json!("y"), &json!(["side"])), "{p}"); + app.session.execute(&id, &p).unwrap(); + let area = app.session.model.state().bodies.last().unwrap().mesh().measure().area; + let want = std::f64::consts::FRAC_PI_2 * 3.0 * 4.0; + assert!((area - want).abs() < want * 2e-3, "{area} vs {want}"); + let fid = app.session.doc.features.last().unwrap().id; + let back = crate::dialogs::for_feature(&app, fid, None).unwrap(); + assert_eq!(back.inputs[0].items, vec![Sel::SketchCurve { id: "side".into() }]); + assert_eq!(back.inputs[1].items, vec![Sel::Axis { name: "y".into() }]); +} + #[test] fn patch_dialog_fills_a_loop_of_edges_that_is_not_planar() { // A box with its top cut away by a sideways cylinder: its rim is a closed loop of four diff --git a/crates/ui-egui/src/timeline.rs b/crates/ui-egui/src/timeline.rs index a1238281..8660271b 100644 --- a/crates/ui-egui/src/timeline.rs +++ b/crates/ui-egui/src/timeline.rs @@ -14,6 +14,7 @@ pub(crate) fn icon_of(k: &FeatureKind) -> &'static str { FeatureKind::SurfaceExtrude { .. } => "surface_extrude", FeatureKind::Unstitch { .. } => "unstitch", FeatureKind::ReverseNormal { .. } => "reverse_normal", + FeatureKind::SurfaceRevolve { .. } => "surface_revolve", FeatureKind::Revolve { .. } => "revolve", FeatureKind::Fillet { .. } => "fillet", FeatureKind::Chamfer { .. } => "chamfer", diff --git a/docs/features.md b/docs/features.md index 198509f1..3ae2bd0e 100644 --- a/docs/features.md +++ b/docs/features.md @@ -1,6 +1,6 @@ # Features -SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **338 commands.** Planned (registered, not built yet): 10. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec ''`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md). +SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **339 commands.** Planned (registered, not built yet): 9. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec ''`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md). ## Sketch (98) @@ -145,7 +145,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm | Align | `solid.align` | SOLID › MODIFY | | Remove | `solid.remove` | SOLID › MODIFY | -## Surfaces (8, 9 planned) +## Surfaces (9, 8 planned) | Command | Id | Toolbar | |---|---|---| @@ -155,7 +155,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm | Extend | `surface.extend` | SURFACE › MODIFY | | Trim | `surface.trim` | SURFACE › MODIFY | | Surface Extrude | `surface.extrude` | SURFACE › CREATE | -| Surface Revolve *(planned: [#757](https://github.com/bherbruck/solidcraft/issues/757))* | `surface.revolve` | SURFACE › CREATE | +| Surface Revolve | `surface.revolve` | SURFACE › CREATE | | Surface Sweep *(planned: [#757](https://github.com/bherbruck/solidcraft/issues/757))* | `surface.sweep` | SURFACE › CREATE | | Surface Loft *(planned: [#757](https://github.com/bherbruck/solidcraft/issues/757))* | `surface.loft` | SURFACE › CREATE | | Offset Surface *(planned: [#758](https://github.com/bherbruck/solidcraft/issues/758))* | `surface.offset` | SURFACE › CREATE |