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 <[email protected]>
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> {