Surface toolset: icons and planned commands (#808)

Icons for the surface toolset, and its planned commands registered and disabled with the issue that tracks each one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Irakli KourtisandClaude Opus 5.5 authored and GitHub committed 2026-10-13 14:28:38 -04:00
1 parent dd084ec9e1
commit c8d668108a
6 files changed
+437 -31

No files matched your search

+22
View File
@@ -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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub planned: Option<&'static str>,
}
pub fn always(_: &Session) -> std::result::Result<(), String> {
+86 -1
View File
@@ -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<Value> {
@@ -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]
@@ -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<Value> {
+284 -18
View File
@@ -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<Pos2> = 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<Vec<(f32, f32)>> = 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::<Vec<_>>(), 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<String> {
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<String> = 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`.
+16 -3
View File
@@ -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 <id> '<json>'`, 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 <id> '<json>'`, 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 |
+24 -9
View File
@@ -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 <id> '<json>'`, 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 |"));
}
}