diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index bdba3856..f8b9ed7e 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -9,6 +9,17 @@ macro_rules! created_doc { }; } +/// A command that is designed and registered (id, toolbar place, icon, params doc) but not built +/// yet. It is disabled with the reason, so the toolbar greys it and says why, and its `run` +/// refuses too, however it is reached. `$issue` is the GitHub issue that tracks building it. +macro_rules! planned { + ($id:literal, $label:literal, $issue:literal) => { + CommandSpec::new($id, $label, |_, _| Err(crate::EngineError::Other(concat!("not supported yet: ", $label, " (issue #", $issue, ")").into()))) + .enabled(|_| Err(concat!("not supported yet (issue #", $issue, ")").into())) + .planned($issue) + }; +} + mod browser; mod clipboard; mod component; @@ -89,6 +100,8 @@ pub struct CommandSpec { /// Reads the evaluated model (the default). A command that reads only the document and the /// session's history runs on a model left stale by a live gesture without evaluating it first. pub reads_model: bool, + /// Registered but not built yet (see `planned!`): the GitHub issue number that tracks it. + pub planned: Option<&'static str>, } impl CommandSpec { @@ -106,8 +119,14 @@ impl CommandSpec { undoable: true, live: None, reads_model: true, + planned: None, } } + /// Not built yet; `issue` tracks building it. + pub const fn planned(mut self, issue: &'static str) -> Self { + self.planned = Some(issue); + self + } /// Takes `live: true`, run by `run`. pub const fn live(mut self, run: LiveRun) -> Self { self.live = Some(run); @@ -161,6 +180,7 @@ impl CommandSpec { params: self.params, enabled: e.is_ok(), disabled_reason: e.err(), + planned: self.planned, } } } @@ -176,6 +196,8 @@ pub struct CommandInfo { pub params: &'static str, pub enabled: bool, pub disabled_reason: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub planned: Option<&'static str>, } pub fn always(_: &Session) -> std::result::Result<(), String> { diff --git a/crates/engine/src/cmd/surface.rs b/crates/engine/src/cmd/surface.rs index 0ee7b23e..f76360ba 100644 --- a/crates/engine/src/cmd/surface.rs +++ b/crates/engine/src/cmd/surface.rs @@ -1,5 +1,5 @@ //! Surface commands: Patch, Stitch, Thicken and Trim (surface bodies and turning them into -//! solids). +//! solids), and the planned surface toolset (`planned!`: registered, not built yet). use serde_json::Value; use solidcraft_doc::{FaceAt, FeatureKind, PlaneRef, ProfileSel, expr::Kind}; @@ -30,6 +30,54 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SURFACE", "MODIFY") .icon("trim") .params("body: surface name; plane: XY|XZ|YZ|plane name|{origin, normal} or tool: {body, point} (a face, its surface extended); keep: [x,y,z] (a point on the side to keep)"), + planned!("surface.extrude", "Surface Extrude", "757") + .at("SURFACE", "CREATE") + .icon("surface_extrude") + .params("planned: the parameters of solid.extrude, with open curve chains as well as profiles (walls only, no caps); caps?: bool (close a closed profile)"), + planned!("surface.revolve", "Surface Revolve", "757") + .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"), + planned!("surface.sweep", "Surface Sweep", "757") + .at("SURFACE", "CREATE") + .icon("surface_sweep") + .params("planned: the parameters of solid.sweep, with an open profile chain as well as a closed one (no caps); caps?: bool"), + planned!("surface.loft", "Surface Loft", "757") + .at("SURFACE", "CREATE") + .icon("surface_loft") + .params("planned: the parameters of solid.loft, with open sections as well as closed ones (no caps); caps?: bool"), + planned!("surface.offset", "Offset Surface", "758") + .at("SURFACE", "CREATE") + .icon("surface_offset") + .params("planned: faces: [{body, point}]; distance: expr (signed, along the normals; 0 copies the faces); chain?: bool (the whole tangent chain of each pick); tolerance?: expr (B-spline fit, default 0.01 mm)"), + planned!("surface.ruled", "Ruled Surface", "762") + .at("SURFACE", "CREATE") + .icon("ruled") + .params("planned: body; edges: [[x,y,z]] (points on an edge chain); distance: expr; mode?: normal|tangent|angle|direction; angle?: expr; direction?: [x,y,z]; flip?: bool; corners?: mitre|round"), + planned!("surface.boundary", "Boundary Surface", "763") + .at("SURFACE", "CREATE") + .icon("boundary") + .params("planned: u: [curves], v?: [curves] (sketch curves, 3D sketch wires or {body, point} edges; 2–4 boundaries, more make a network); continuity?: [{curve, g: 0|1|2}] for edge boundaries; operation?: new|join"), + planned!("surface.blend", "Blend Surface", "764") + .at("SURFACE", "CREATE") + .icon("surface_blend") + .params("planned: from, to: {body, edges: [[x,y,z]]} (two edge chains apart); continuity?: [g0|g1|g2, g0|g1|g2] (each side); bulge?: [number, number]; align?: auto|[[x,y,z], [x,y,z]]; stitch?: bool"), + planned!("surface.untrim", "Untrim", "761") + .at("SURFACE", "MODIFY") + .icon("untrim") + .params("planned: face: {body, point}; holes?: all|[[x,y,z]] (points on the inner loops to remove); outer?: bool (restore the natural bounds); in_place?: bool (a surface body only)"), + planned!("surface.unstitch", "Unstitch", "766") + .at("SURFACE", "MODIFY") + .icon("unstitch") + .params("planned: body; faces?: [[x,y,z]] (points on the faces to split off; default: every face its own surface body)"), + planned!("surface.reverse_normal", "Reverse Normal", "766") + .at("SURFACE", "MODIFY") + .icon("reverse_normal") + .params("planned: body (a surface); faces?: [[x,y,z]] (default: all of them)"), + planned!("surface.fillet", "Surface Fillet", "769") + .at("SURFACE", "MODIFY") + .icon("surface_fillet") + .params("planned: a, b: {body, point} (two faces of separate bodies that meet or cross; the points pick the side the ball rolls on); radius: expr; trim?: both|first|none"), ]; fn patch(s: &mut Session, p: &Value) -> Result { @@ -129,6 +177,43 @@ mod tests { solidcraft_kernel::measure(&st.body(name).unwrap().body).unwrap().volume } + /// 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.extrude", + "surface.revolve", + "surface.sweep", + "surface.loft", + "surface.offset", + "surface.ruled", + "surface.boundary", + "surface.blend", + "surface.untrim", + "surface.unstitch", + "surface.reverse_normal", + "surface.fillet", + "inspect.continuity", + ]; + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "B"})); + let before = s.doc.features.len(); + for id in planned { + let spec = crate::find_command(id).unwrap_or_else(|| panic!("{id} is not registered")); + let info = spec.info(&s); + assert!(!info.enabled, "{id} must stay disabled until it is built"); + let why = info.disabled_reason.unwrap_or_default(); + assert!(why.starts_with("not supported yet (issue #"), "{id}: {why}"); + assert!(!spec.icon.is_empty() && !spec.params.is_empty(), "{id}: icon and params doc"); + assert!(s.execute(id, &json!({"body": "B"})).is_err(), "{id} ran"); + let e = (spec.run)(&mut s, &json!({})).unwrap_err().to_string(); + assert!(e.starts_with("not supported yet: ") && e.contains("(issue #"), "{id}: {e}"); + } + assert_eq!(s.doc.features.len(), before, "a planned command changed the design"); + } + /// A rectangle patched, trimmed by a plane and thickened; a box's open top patched; a rim /// that is not planar patched. #[test] diff --git a/crates/engine/src/cmd/surface_analysis.rs b/crates/engine/src/cmd/surface_analysis.rs index f90d4c44..dbcea9f0 100644 --- a/crates/engine/src/cmd/surface_analysis.rs +++ b/crates/engine/src/cmd/surface_analysis.rs @@ -34,6 +34,11 @@ pub static COMMANDS: &[CommandSpec] = &[ .icon("curvature_map") .noundo() .params("radius?: mm shown red (default: a tenth of the model's size); clear?: true turns the analysis off"), + planned!("inspect.continuity", "Continuity Analysis", "770") + .at("SURFACE", "INSPECT") + .icon("continuity") + .noundo() + .params("planned: body?; edges?: [[x,y,z]] (default: every edge of the body); returns each edge's worst gap (G0), normal angle (G1) and curvature jump (G2); clear?: true turns the analysis off"), ]; fn set(s: &mut Session, p: &Value, a: SurfaceAnalysis) -> Result { diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index d55be708..d2e48bf3 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -1,7 +1,32 @@ //! Toolbar and browser icons, drawn in code (no image assets). Each icon is designed on a //! 24 × 24 grid and scaled to the target rectangle. -use egui::{Color32, Painter, Pos2, Rect, Shape, Stroke, pos2, vec2}; +use egui::{Color32, Mesh, Painter, Pos2, Rect, Shape, Stroke, pos2, vec2}; + +/// Surface sheets: a sheet, a second sheet beside it, a lighter sheet (an opening, a blend or +/// round strip), and the iso lines and comb teeth drawn on sheets. +const SHEET: Color32 = Color32::from_rgb(170, 200, 240); +const SHEET_2: Color32 = Color32::from_rgb(150, 185, 235); +const SHEET_LIGHT: Color32 = Color32::from_rgb(214, 229, 248); +const SHEET_LINE: Color32 = Color32::from_rgb(127, 166, 218); + +/// `n + 1` points along the cubic Bézier curve with control points `c`. +fn bezier(c: [(f32, f32); 4], n: usize) -> Vec<(f32, f32)> { + (0..=n) + .map(|i| i as f32 / n as f32) + .map(|t| { + let u = 1.0 - t; + let w = [u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t]; + (w.iter().zip(&c).map(|(w, q)| w * q.0).sum(), w.iter().zip(&c).map(|(w, q)| w * q.1).sum()) + }) + .collect() +} + +/// `n + 1` points along an ellipse from angle `a0` to `a1` (y down: angles below the centre +/// first). +fn ellipse(x: f32, y: f32, rx: f32, ry: f32, a0: f32, a1: f32, n: usize) -> Vec<(f32, f32)> { + (0..=n).map(|i| a0 + (a1 - a0) * i as f32 / n as f32).map(|a| (x + rx * a.cos(), y + ry * a.sin())).collect() +} struct Pen<'a> { p: &'a Painter, @@ -60,6 +85,30 @@ impl Pen<'_> { self.poly(&side, self.fill.gamma_multiply(0.7), self.ink); self.poly(&top, self.fill, self.ink); } + /// A cubic Bézier curve through control points `c`. + fn curve(&self, c: [(f32, f32); 4], col: Color32) { + self.line(&bezier(c, 16), col); + } + fn dashed(&self, pts: &[(f32, f32)], c: Color32) { + let v: Vec = pts.iter().map(|(x, y)| self.at(*x, *y)).collect(); + self.p.add(Shape::dashed_line(&v, Stroke::new(self.w, c), 2.0 * self.s(), 1.6 * self.s())); + } + /// Fill the strip between two polylines that run the same way, such as the front and back + /// edges of a curved sheet. Unlike `poly`, the strip need not be convex. + fn band(&self, a: &[(f32, f32)], b: &[(f32, f32)], fill: Color32) { + let mut m = Mesh::default(); + for (p, q) in a.iter().zip(b) { + m.colored_vertex(self.at(p.0, p.1), fill); + m.colored_vertex(self.at(q.0, q.1), fill); + } + let n = a.len().min(b.len()) as u32; + for i in 1..n { + let k = 2 * i; + m.add_triangle(k - 2, k - 1, k); + m.add_triangle(k - 1, k + 1, k); + } + self.p.add(Shape::mesh(m)); + } } /// The stroke width for the Normal/Construction/Centerline line-type icons. It is heavier than @@ -858,22 +907,33 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce } "patch" => { // A wire loop with a surface spanned across it. - pen.poly(&[(4.0, 16.0), (9.0, 6.0), (19.0, 5.0), (20.0, 15.0), (12.0, 20.0)], Color32::from_rgb(170, 200, 240), ink); + pen.poly(&[(4.0, 16.0), (9.0, 6.0), (19.0, 5.0), (20.0, 15.0), (12.0, 20.0)], SHEET, ink); pen.line(&[(4.0, 16.0), (9.0, 6.0), (19.0, 5.0), (20.0, 15.0), (12.0, 20.0), (4.0, 16.0)], a); } "stitch" => { // Two surface patches sewn along their shared edge. - pen.poly(&[(3.0, 18.0), (11.0, 14.0), (11.0, 4.0), (3.0, 8.0)], Color32::from_rgb(170, 200, 240), ink); - pen.poly(&[(13.0, 14.0), (21.0, 18.0), (21.0, 8.0), (13.0, 4.0)], Color32::from_rgb(150, 185, 235), ink); + pen.poly(&[(3.0, 18.0), (11.0, 14.0), (11.0, 4.0), (3.0, 8.0)], SHEET, ink); + pen.poly(&[(13.0, 14.0), (21.0, 18.0), (21.0, 8.0), (13.0, 4.0)], SHEET_2, ink); for y in [6.0, 9.0, 12.0] { pen.line(&[(10.0, y), (14.0, y + 1.5)], a); } } "thicken" => { - // A curved sheet given thickness: its offset copy and the side walls. - pen.arc(12.0, 20.0, 11.0, 3.6, 5.8, ink); - pen.arc(12.0, 20.0, 7.0, 3.6, 5.8, a); - pen.arrow((12.0, 9.0), (12.0, 13.5), a); + // A wavy sheet (accent edges) grown downward into a slab, shaded like `iso_box`. + let f = [(2.0, 12.5), (7.0, 9.5), (12.0, 15.5), (17.0, 12.5)]; + let b = [(6.4, 8.9), (11.4, 5.9), (16.4, 11.9), (21.4, 8.9)]; + let d = f.map(|(x, y)| (x, y + 6.0)); + pen.band(&bezier(b, 16), &bezier(f, 16), SHEET); + pen.band(&bezier(f, 16), &bezier(d, 16), pen.fill.gamma_multiply(0.85)); + pen.poly(&[(17.0, 12.5), (21.4, 8.9), (21.4, 14.9), (17.0, 18.5)], pen.fill.gamma_multiply(0.7), ink); + pen.line(&[(2.0, 12.5), (2.0, 18.5)], ink); + pen.line(&[(17.0, 12.5), (17.0, 18.5)], ink); + pen.line(&[(21.4, 8.9), (21.4, 14.9)], ink); + pen.curve(d, ink); + pen.line(&[(6.4, 8.9), (2.0, 12.5)], ink); + pen.line(&[(21.4, 8.9), (17.0, 12.5)], a); + pen.curve(b, a); + pen.curve(f, a); } "extend" => { pen.line(&[(20.0, 3.0), (20.0, 21.0)], ink); @@ -896,19 +956,191 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce "blend" => { pen.line(&[(2.0, 19.0), (8.0, 15.0)], ink); pen.line(&[(16.0, 9.0), (22.0, 5.0)], ink); - let c = [(8.0_f32, 15.0_f32), (12.5, 12.0), (11.5, 12.0), (16.0, 9.0)]; - let pts: Vec<(f32, f32)> = (0..=16) - .map(|i| i as f32 / 16.0) - .map(|t| { - let u = 1.0 - t; - let w = [u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t]; - (w.iter().zip(&c).map(|(w, q)| w * q.0).sum(), w.iter().zip(&c).map(|(w, q)| w * q.1).sum()) - }) - .collect(); - pen.line(&pts, a); + pen.curve([(8.0, 15.0), (12.5, 12.0), (11.5, 12.0), (16.0, 9.0)], a); pen.dot(8.0, 15.0, ink); pen.dot(16.0, 9.0, ink); } + // Surface modelling: open sheets (no caps) in the sheet colours, the input or the result + // in the accent. + "surface_extrude" => { + // An open profile (accent) pulled up into a sheet. + let top = [(4.0, 11.0), (8.0, 8.0), (13.0, 14.0), (20.0, 10.0)]; + let bottom = [(4.0, 20.0), (8.0, 17.0), (13.0, 23.0), (20.0, 19.0)]; + pen.band(&bezier(top, 16), &bezier(bottom, 16), SHEET); + pen.line(&[(4.0, 11.0), (4.0, 20.0)], ink); + pen.line(&[(20.0, 10.0), (20.0, 19.0)], ink); + pen.curve(top, ink); + pen.curve(bottom, a); + pen.arrow((12.0, 8.5), (12.0, 2.5), a); + } + "surface_revolve" => { + // An open profile (accent) turned about the axis into a hollow band. + let left = [(5.0, 6.0), (7.0, 10.0), (5.0, 14.0), (6.0, 18.0)]; + let right = [(19.0, 6.0), (17.0, 10.0), (19.0, 14.0), (18.0, 18.0)]; + pen.band(&bezier(left, 16), &bezier(right, 16), SHEET); + let front = ellipse(12.0, 18.0, 6.0, 2.2, 0.0, std::f32::consts::PI, 16); + pen.poly(&front, SHEET, Color32::TRANSPARENT); + // A closed fill takes each point once: a repeated end point spikes `poly`'s edge. + let mut opening = ellipse(12.0, 6.0, 7.0, 2.2, 0.0, std::f32::consts::TAU, 32); + opening.pop(); + pen.poly(&opening, SHEET_LIGHT, ink); + pen.line(&front, ink); + pen.curve(left, ink); + pen.line(&[(12.0, 1.5), (12.0, 3.8)], ink); + pen.line(&[(12.0, 20.2), (12.0, 22.5)], ink); + pen.curve(right, a); + } + "surface_sweep" => { + // A straight profile (accent) carried along a curved path: a ribbon. + let near = [(4.0, 13.0), (8.0, 5.0), (14.0, 5.0), (20.0, 9.0)]; + let far = [(4.0, 20.0), (8.0, 12.0), (14.0, 12.0), (20.0, 16.0)]; + pen.band(&bezier(near, 16), &bezier(far, 16), SHEET); + pen.curve(near, ink); + pen.curve(far, ink); + pen.line(&[(4.0, 13.0), (4.0, 20.0)], a); + pen.line(&[(20.0, 9.0), (20.0, 16.0)], ink); + } + "surface_loft" => { + // Two open sections (accent) joined by a sheet. + let top = [(5.0, 6.0), (9.0, 3.0), (14.0, 8.0), (19.0, 5.0)]; + let bottom = [(3.0, 19.0), (8.0, 15.0), (14.0, 22.0), (21.0, 18.0)]; + pen.band(&bezier(top, 16), &bezier(bottom, 16), SHEET); + pen.line(&[(5.0, 6.0), (3.0, 19.0)], ink); + pen.line(&[(19.0, 5.0), (21.0, 18.0)], ink); + pen.curve(top, a); + pen.curve(bottom, a); + } + "surface_offset" => { + // A wavy sheet and its offset copy (dashed), the distance shown by the arrow. + let back = [(6.0, 14.0), (10.0, 11.0), (15.0, 17.0), (21.0, 14.0)]; + let front = [(3.0, 20.0), (7.0, 17.0), (12.0, 23.0), (18.0, 20.0)]; + pen.band(&bezier(back, 16), &bezier(front, 16), SHEET); + pen.curve(back, ink); + pen.curve(front, ink); + pen.line(&[(6.0, 14.0), (3.0, 20.0)], ink); + pen.line(&[(21.0, 14.0), (18.0, 20.0)], ink); + pen.dashed(&bezier(back.map(|(x, y)| (x, y - 9.0)), 16), a); + pen.dashed(&bezier(front.map(|(x, y)| (x, y - 9.0)), 16), a); + pen.dashed(&[(6.0, 5.0), (3.0, 11.0)], a); + pen.dashed(&[(21.0, 5.0), (18.0, 11.0)], a); + pen.arrow((12.0, 17.0), (12.0, 8.5), a); + } + "ruled" => { + // A strip hanging off a block's edge (accent), ruled by straight lines. + pen.poly(&[(2.0, 12.0), (12.0, 12.0), (12.0, 22.0), (2.0, 22.0)], pen.fill, ink); + pen.poly(&[(12.0, 12.0), (21.0, 4.0), (21.0, 14.0), (12.0, 22.0)], SHEET, ink); + pen.line(&[(15.0, 9.3), (15.0, 19.3)], a); + pen.line(&[(18.0, 6.7), (18.0, 16.7)], a); + pen.line(&[(12.0, 12.0), (12.0, 22.0)], a); + } + "boundary" => { + // Four boundary curves (accent) meeting at corners, the patch between them a Coons + // patch with its iso lines. + let (c00, c10, c11, c01) = ((4.0, 17.0), (10.0, 5.0), (20.0, 7.0), (17.0, 20.0)); + let left = [c00, (5.0, 12.0), (8.0, 8.0), c10]; + let top = [c10, (13.0, 4.0), (17.0, 4.0), c11]; + let right = [c01, (19.0, 16.0), (20.0, 11.0), c11]; + let bottom = [c00, (8.0, 20.0), (13.0, 21.0), c01]; + let n = 12; + let lerp = |p: (f32, f32), q: (f32, f32), s: f32| (p.0 + (q.0 - p.0) * s, p.1 + (q.1 - p.1) * s); + let (b, t) = (bezier(bottom, n), bezier(top, n)); + // Each row runs across the patch (u) at one height (v) up the left and right sides. + let rows: Vec> = bezier(left, n) + .into_iter() + .zip(bezier(right, n)) + .enumerate() + .map(|(j, (lv, rv))| { + let v = j as f32 / n as f32; + b.iter() + .zip(&t) + .enumerate() + .map(|(i, (bu, tu))| { + let u = i as f32 / n as f32; + let corners = lerp(lerp(c00, c01, u), lerp(c10, c11, u), v); + let (ruled_v, ruled_u) = (lerp(*bu, *tu, v), lerp(lv, rv, u)); + (ruled_v.0 + ruled_u.0 - corners.0, ruled_v.1 + ruled_u.1 - corners.1) + }) + .collect() + }) + .collect(); + for (lo, hi) in rows.iter().zip(rows.iter().skip(1)) { + pen.band(lo, hi, SHEET); + } + for j in [n / 3, 2 * n / 3] { + if let Some(r) = rows.get(j) { + pen.line(r, SHEET_LINE); + } + pen.line(&rows.iter().filter_map(|r| r.get(j).copied()).collect::>(), SHEET_LINE); + } + for c in [left, top, right, bottom] { + pen.curve(c, a); + } + for (x, y) in [c00, c10, c11, c01] { + pen.dot(x, y, ink); + } + } + "surface_blend" => { + // Two separate sheets at different heights bridged by a smooth strip (accent) that + // leaves both edges tangent. + let near = [(13.0, 20.0), (8.5, 20.0), (10.5, 11.0), (6.0, 11.0)]; + let far = near.map(|(x, y)| (x + 4.4, y - 3.6)); + pen.poly(&[(2.0, 11.0), (6.0, 11.0), (10.4, 7.4), (6.4, 7.4)], SHEET, ink); + pen.band(&bezier(near, 16), &bezier(far, 16), SHEET_LIGHT); + pen.curve(far, a); + pen.poly(&[(13.0, 20.0), (17.0, 20.0), (21.4, 16.4), (17.4, 16.4)], SHEET, ink); + pen.curve(near, a); + } + "untrim" => { + // A trimmed sheet whose hole (dashed, accent) is filled back in. + pen.poly(&[(3.0, 17.0), (9.0, 5.0), (21.0, 7.0), (16.0, 20.0)], SHEET, ink); + let hole = ellipse(12.2, 12.3, 4.2, 3.2, 0.0, std::f32::consts::TAU, 32); + pen.poly(hole.get(1..).unwrap_or_default(), SHEET_LIGHT, Color32::TRANSPARENT); + pen.dashed(&hole, a); + } + "unstitch" => { + // Two sheets pulled apart from their shared edge. + pen.poly(&[(2.0, 18.0), (9.0, 14.0), (9.0, 4.0), (2.0, 8.0)], SHEET, ink); + pen.poly(&[(15.0, 14.0), (22.0, 18.0), (22.0, 8.0), (15.0, 4.0)], SHEET_2, ink); + pen.arrow((10.8, 11.0), (7.0, 11.0), a); + pen.arrow((13.2, 11.0), (17.0, 11.0), a); + } + "reverse_normal" => { + // A sheet with its old normal (faded) and the flipped one (accent). + pen.poly(&[(2.0, 14.0), (14.0, 10.0), (22.0, 13.0), (10.0, 18.0)], SHEET, ink); + pen.arrow((12.0, 14.0), (12.0, 3.5), ink.gamma_multiply(0.5)); + pen.arrow((12.0, 14.0), (12.0, 22.5), a); + } + "surface_fillet" => { + // A wall sheet and a floor sheet joined by a round strip (accent edges). + let near = [(3.0, 16.0), (3.0, 18.8), (5.2, 21.0), (8.0, 21.0)]; + let far = near.map(|(x, y)| (x + 4.4, y - 3.6)); + pen.poly(&[(3.0, 16.0), (3.0, 8.0), (7.4, 4.4), (7.4, 12.4)], SHEET_2, ink); + pen.poly(&[(8.0, 21.0), (16.0, 21.0), (20.4, 17.4), (12.4, 17.4)], SHEET, ink); + pen.band(&bezier(near, 16), &bezier(far, 16), SHEET_LIGHT); + pen.line(&[(4.5, 19.5), (8.9, 15.9)], SHEET_LINE); + pen.curve(far, a); + pen.curve(near, a); + } + "continuity" => { + // A straight curve joining a curved one (accent) at a point, with the curved one's + // curvature comb: teeth that grow as the curve tightens. + pen.line(&[(2.0, 17.0), (12.0, 13.0)], ink); + let c = [(12.0, 13.0), (16.5, 11.2), (20.0, 8.0), (21.0, 3.0)]; + let pts = bezier(c, 10); + let mut tips = Vec::new(); + for (i, ((x, y), (x1, y1))) in pts.iter().zip(pts.iter().skip(1)).enumerate() { + let len = (x1 - x).hypot(y1 - y).max(1e-6); + let h = 0.8 + 4.2 * i as f32 / 10.0; + let tip = (x + (y1 - y) / len * h, y - (x1 - x) / len * h); + if i % 2 == 0 { + pen.line(&[(*x, *y), tip], SHEET_LINE); + } + tips.push(tip); + } + pen.line(&tips, SHEET_LINE); + pen.curve(c, a); + pen.dot(12.0, 13.0, a); + } // Assembly: joints (accent: the joint itself). "joint" => { pen.iso_box(2.0, 13.0, 7.0, 5.0, 5.0); @@ -1083,6 +1315,40 @@ mod tests { format!("{:?}", out.shapes) } + /// Command icons not drawn yet (the fallback square) or drawn past their square, from before + /// this check. Remove a name when its icon is fixed; never add one. + const KNOWN_GAPS: &[&str] = + &["cylinder", "pipe", "offset_face", "thread", "boundary_fill", "coil", "rib", "emboss", "capture", "revert", "camera", "material"]; + + /// What is wrong with the icon `name`: not drawn (the fallback square), or drawn past its + /// square by more than a stroke width. + fn icon_problem(name: &str) -> Option { + if drawn(name, Color32::BLACK, Color32::WHITE, Color32::BLUE) == drawn("", Color32::BLACK, Color32::WHITE, Color32::BLUE) { + return Some("no icon drawn".into()); + } + let side = 48.0; + let r = Rect::from_min_size(Pos2::ZERO, vec2(side, side)); + let ctx = egui::Context::default(); + let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(100.0, 100.0))), ..Default::default() }; + let out = ctx.run_ui(input, |ui| paint(ui.painter(), r, name, Color32::BLACK, Color32::WHITE, Color32::BLUE)); + let used = out.shapes.iter().fold(Rect::NOTHING, |b, s| b.union(s.shape.visual_bounding_rect())); + (!r.expand(side / 24.0 * 1.4).contains_rect(used)).then(|| format!("drawn over {used:?}")) + } + + /// Every command's icon is drawn and stays inside its square. + #[test] + fn every_command_icon_is_drawn_inside_its_square() { + let bad: Vec = solidcraft_engine::command_specs() + .into_iter() + .filter(|c| !c.icon.is_empty() && !KNOWN_GAPS.contains(&c.icon)) + .filter_map(|c| icon_problem(c.icon).map(|why| format!("{} ({}): {why}", c.id, c.icon))) + .collect(); + assert!(bad.is_empty(), "{}", bad.join("\n")); + for gap in KNOWN_GAPS { + assert!(icon_problem(gap).is_some(), "`{gap}` is fixed now: take it off KNOWN_GAPS"); + } + } + /// The Sketch Palette and the toolbar both rely on this contract. Construction and /// centerline must track whatever accent colour the caller passes (both callers pass the /// theme's construction orange) and must not change with `ink`. diff --git a/docs/features.md b/docs/features.md index f8f529f0..b77054dc 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`). **335 commands.** 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`). **335 commands.** Planned (registered, not built yet): 13. 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 (5) +## Surfaces (5, 12 planned) | Command | Id | Toolbar | |---|---|---| @@ -154,6 +154,18 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm | Thicken | `surface.thicken` | SURFACE › CREATE | | Extend | `surface.extend` | SURFACE › MODIFY | | Trim | `surface.trim` | SURFACE › MODIFY | +| Surface Extrude *(planned: [#757](https://github.com/bherbruck/solidcraft/issues/757))* | `surface.extrude` | SURFACE › CREATE | +| Surface Revolve *(planned: [#757](https://github.com/bherbruck/solidcraft/issues/757))* | `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 | +| Ruled Surface *(planned: [#762](https://github.com/bherbruck/solidcraft/issues/762))* | `surface.ruled` | SURFACE › CREATE | +| Boundary Surface *(planned: [#763](https://github.com/bherbruck/solidcraft/issues/763))* | `surface.boundary` | SURFACE › CREATE | +| Blend Surface *(planned: [#764](https://github.com/bherbruck/solidcraft/issues/764))* | `surface.blend` | SURFACE › CREATE | +| Untrim *(planned: [#761](https://github.com/bherbruck/solidcraft/issues/761))* | `surface.untrim` | SURFACE › MODIFY | +| Unstitch *(planned: [#766](https://github.com/bherbruck/solidcraft/issues/766))* | `surface.unstitch` | SURFACE › MODIFY | +| Reverse Normal *(planned: [#766](https://github.com/bherbruck/solidcraft/issues/766))* | `surface.reverse_normal` | SURFACE › MODIFY | +| Surface Fillet *(planned: [#769](https://github.com/bherbruck/solidcraft/issues/769))* | `surface.fillet` | SURFACE › MODIFY | ## Construction geometry (19) @@ -281,7 +293,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm | Copy Features | `timeline.copy` | – | | Paste Features | `timeline.paste` | – | -## Inspect (12) +## Inspect (12, 1 planned) | Command | Id | Toolbar | |---|---|---| @@ -294,6 +306,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm | Environment Map Analysis | `inspect.environment_map` | SKETCH › INSPECT | | Accessibility Analysis | `inspect.accessibility` | SKETCH › INSPECT | | Curvature Map Analysis | `inspect.curvature_map` | SKETCH › INSPECT | +| Continuity Analysis *(planned: [#770](https://github.com/bherbruck/solidcraft/issues/770))* | `inspect.continuity` | SURFACE › INSPECT | | Interference | `inspect.interference` | SOLID › INSPECT | | Measure (I) | `inspect.measure` | SOLID › INSPECT | | Section Analysis | `inspect.section` | SOLID › INSPECT | diff --git a/xtask/src/features.rs b/xtask/src/features.rs index 3eea96ef..72013bf8 100644 --- a/xtask/src/features.rs +++ b/xtask/src/features.rs @@ -3,7 +3,8 @@ //! The page lists every registered command by area (the first part of its dotted id), with its //! label, id and toolbar place. It comes only from SolidCraft's command registry //! (`solidcraft-cli commands`), so it can't drift from what the program does; it compares with -//! nothing else. +//! nothing else. Planned commands (registered, not built yet) are listed as planned, with their +//! issue, and are not counted. use std::path::Path; @@ -59,16 +60,26 @@ pub fn page(cmds: &[Value]) -> String { for p in prefixes.iter().filter(|p| !AREAS.iter().any(|(a, _)| a == p)) { order.push((p.clone(), format!("`{p}.*`"))); } + let planned = |c: &Value| c["planned"].as_str().map(str::to_string); + let built = cmds.iter().filter(|c| planned(c).is_none()).count(); + let later = match cmds.len() - built { + 0 => String::new(), + n => format!(" Planned (registered, not built yet): {n}."), + }; let mut md = format!( "# Features\n\nSolidCraft's commands by area, generated by `cargo xtask features` from the command registry \ - (`solidcraft-cli commands`). **{} commands.** Every command runs the same way from the toolbar, a script, \ + (`solidcraft-cli commands`). **{built} commands.**{later} 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).\n", - cmds.len() + [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md).\n" ); for (prefix, heading) in &order { let in_area: Vec<&Value> = cmds.iter().filter(|c| area(&s(c, "id")) == prefix).collect(); - md += &format!("\n## {heading} ({})\n\n| Command | Id | Toolbar |\n|---|---|---|\n", in_area.len()); + let n = in_area.iter().filter(|c| planned(c).is_none()).count(); + let count = match in_area.len() - n { + 0 => n.to_string(), + p => format!("{n}, {p} planned"), + }; + md += &format!("\n## {heading} ({count})\n\n| Command | Id | Toolbar |\n|---|---|---|\n"); for c in in_area { let place = match (s(c, "tab"), s(c, "panel")) { (t, _) if t.is_empty() => "–".to_string(), @@ -76,7 +87,8 @@ pub fn page(cmds: &[Value]) -> String { (t, p) => format!("{t} › {p}"), }; let key = c["shortcut"].as_str().map(|k| format!(" ({k})")).unwrap_or_default(); - md += &format!("| {}{key} | `{}` | {place} |\n", s(c, "label").replace('|', "/"), s(c, "id")); + let note = planned(c).map(|i| format!(" *(planned: [#{i}](https://github.com/bherbruck/solidcraft/issues/{i}))*")).unwrap_or_default(); + md += &format!("| {}{key}{note} | `{}` | {place} |\n", s(c, "label").replace('|', "/"), s(c, "id")); } } md @@ -91,14 +103,17 @@ mod tests { let cmds: Value = serde_json::from_str( r#"[{"id":"solid.extrude","label":"Extrude","tab":"SOLID","panel":"CREATE","shortcut":"E"}, {"id":"sketch.line","label":"Line","tab":"SKETCH","panel":"CREATE"}, - {"id":"view.fit","label":"Fit","tab":"","panel":""}]"#, + {"id":"view.fit","label":"Fit","tab":"","panel":""}, + {"id":"solid.knit","label":"Knit","tab":"SOLID","panel":"CREATE","planned":"9"}]"#, ) .unwrap(); let md = page(cmds.as_array().unwrap()); - assert!(md.contains("**3 commands.**")); - let (sk, so, other) = (md.find("## Sketch (1)").unwrap(), md.find("## Solid modelling (1)").unwrap(), md.find("## `view.*` (1)").unwrap()); + assert!(md.contains("**3 commands.** Planned (registered, not built yet): 1.")); + let (sk, so, other) = + (md.find("## Sketch (1)").unwrap(), md.find("## Solid modelling (1, 1 planned)").unwrap(), md.find("## `view.*` (1)").unwrap()); assert!(sk < so && so < other); assert!(md.contains("| Extrude (E) | `solid.extrude` | SOLID › CREATE |")); assert!(md.contains("| Fit | `view.fit` | – |")); + assert!(md.contains("| Knit *(planned: [#9](https://github.com/bherbruck/solidcraft/issues/9))* | `solid.knit` | SOLID › CREATE |")); } }