Surface tool dialogs: Patch (sketch profiles or a loop of edges), Stitch (tolerance), Thicken (thickness, one side or symmetric, operation), Extend (edges, distance), Trim (tool plane or face, side to keep) and Split Face; Split Body takes any face as its tool (a curved face's surface extended). Live previews and Edit Feature; icons for patch, stitch and thicken drawn in code

Fixes #19

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-09 19:59:41 +00:00
1 parent a1a6e2b624
commit eef2430bc7
5 files changed
+366 -19

No files matched your search

+32 -19
View File
@@ -17,8 +17,8 @@ use crate::selection::{self, AXES, Accept, BODIES, CURVES, EDGES, FACES, PLANAR_
use crate::theme::Tokens;
use crate::viewport::Hit;
const OPS: [&str; 4] = ["new", "join", "cut", "intersect"];
const OP_LABELS: [&str; 4] = ["New Body", "Join", "Cut", "Intersect"];
pub(crate) const OPS: [&str; 4] = ["new", "join", "cut", "intersect"];
pub(crate) const OP_LABELS: [&str; 4] = ["New Body", "Join", "Cut", "Intersect"];
const DIRS: [&str; 4] = ["positive", "negative", "symmetric", "positive"];
/// The "Two sides" direction (a second distance the other way).
pub const TWO_SIDES: usize = 3;
@@ -222,6 +222,8 @@ pub enum Kind {
Assembly(crate::dialogs_assembly::Asm),
/// Sheet metal (`dialogs_sheet`).
Sheet(crate::dialogs_sheet::Sm),
/// Surface tools and Split Face (`dialogs_surface`).
Surface(crate::dialogs_surface::Surf),
/// Plastic features (`dialogs_plastic`).
Plastic(crate::dialogs_plastic::Pl),
/// Appearance (`dialogs_appearance`).
@@ -360,10 +362,9 @@ impl Dialog {
Kind::AnglePlane { angle: "45 deg".into() },
vec![SelInput::new("Plane", PLANES, false), SelInput::new("Axis", AXES, false)],
),
"solid.split_body" => Dialog::new(
Kind::Split,
vec![SelInput::new("Body to split", BODIES, false), SelInput::new("Splitting plane", PLANES | PLANAR_FACES, false)],
),
"solid.split_body" => {
Dialog::new(Kind::Split, vec![SelInput::new("Body to split", BODIES, false), SelInput::new("Splitting tool", PLANES | FACES, false)])
}
"solid.scale" => Dialog::new(Kind::Scale { factor: "2".into() }, vec![SelInput::new("Bodies", BODIES, true)]),
"solid.offset_face" => Dialog::new(Kind::OffsetFaces { distance: "2 mm".into() }, vec![SelInput::new("Faces", PLANAR_FACES, true)]),
"solid.thread" => {
@@ -427,6 +428,7 @@ impl Dialog {
_ => {
let (kind, inputs) = crate::dialogs_assembly::start(app, id)
.or_else(|| crate::dialogs_sheet::start(app, id))
.or_else(|| crate::dialogs_surface::start(app, id))
.or_else(|| crate::dialogs_plastic::start(app, id))
.or_else(|| crate::dialogs_appearance::start(app, id))
.or_else(|| crate::dialogs_motion::start(app, id))
@@ -530,6 +532,7 @@ impl Dialog {
match &self.kind {
Kind::Assembly(k) => return k.previews(),
Kind::Sheet(k) => return k.previews(),
Kind::Surface(_) => return true,
Kind::Plastic(k) => return k.previews(),
Kind::Part(_) => return true,
_ => {}
@@ -588,6 +591,7 @@ impl Dialog {
Kind::Scale { factor } => ("Scale", ValueKind::Unitless, factor),
Kind::OffsetFaces { distance } => ("Distance", ValueKind::Length, distance),
Kind::Sheet(k) => return k.primary(&self.inputs),
Kind::Surface(k) => return k.primary(),
Kind::Plastic(k) => return k.primary(),
_ => return None,
})
@@ -809,6 +813,7 @@ fn title(k: &Kind) -> &'static str {
Kind::ConfirmDelete { .. } => "DELETE FEATURE",
Kind::Assembly(k) => k.title(),
Kind::Sheet(k) => k.title(),
Kind::Surface(k) => k.title(),
Kind::Plastic(k) => k.title(),
Kind::Appearance(_) => "APPEARANCE",
Kind::Motion(k) => k.title(),
@@ -1754,6 +1759,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
}
Kind::Assembly(k) => enter |= crate::dialogs_assembly::rows(app, ui, k, &mut d.inputs, &mut d.active),
Kind::Sheet(k) => enter |= crate::dialogs_sheet::rows(app, ui, k, &mut d.inputs),
Kind::Surface(k) => enter |= crate::dialogs_surface::rows(ui, k),
Kind::Plastic(k) => enter |= crate::dialogs_plastic::rows(app, ui, k, &d.inputs),
Kind::Appearance(k) => enter |= crate::dialogs_appearance::rows(app, ui, k, &mut d.inputs),
Kind::Motion(k) => enter |= crate::dialogs_motion::rows(app, ui, k, &mut d.inputs),
@@ -2383,15 +2389,16 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
need(0, "the body to split")?;
need(1, "the splitting plane")?;
let body = body_names(0).into_iter().next().unwrap_or_default();
let plane = match sels(d, 1).first() {
Some(Sel::Plane { name }) => json!(name),
Some(Sel::Face { body, index, point }) => {
let (_, n) = planar_face(s, body, *index).ok_or("the face must be planar")?;
json!({"origin": pt(*point), "normal": pt(n)})
}
_ => return Err("pick a plane or a planar face".into()),
// A plane, a planar face's plane, or any other face (its surface extended).
let p = match sels(d, 1).first() {
Some(Sel::Plane { name }) => json!({"body": body, "plane": name}),
Some(Sel::Face { body: b, index, point }) => match planar_face(s, b, *index) {
Some((_, n)) => json!({"body": body, "plane": {"origin": pt(*point), "normal": pt(n)}}),
None => json!({"body": body, "tool": {"body": b, "point": pt(*point)}}),
},
_ => return Err("pick a plane or a face".into()),
};
("solid.split_body", json!({"body": body, "plane": plane}))
("solid.split_body", p)
}
Kind::Scale { factor } => {
need(0, "bodies")?;
@@ -2529,6 +2536,7 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. } | Kind::Preferences => return Ok(Vec::new()),
Kind::Assembly(k) => return crate::dialogs_assembly::commands(app, k, &d.inputs),
Kind::Sheet(k) => return crate::dialogs_sheet::commands(app, k, &d.inputs, &d.extra),
Kind::Surface(k) => return crate::dialogs_surface::commands(app, k, &d.inputs, &d.extra),
Kind::Plastic(k) => return crate::dialogs_plastic::commands(app, k, &d.inputs, &d.extra),
Kind::Appearance(k) => return Ok(crate::dialogs_appearance::commands(k, &d.inputs)),
Kind::Motion(k) => return crate::dialogs_motion::commands(app, k, &d.inputs),
@@ -2691,7 +2699,7 @@ pub(crate) fn face_sel(s: &Session, p: Vec3) -> Option<Sel> {
best.filter(|(d, _)| *d < 1e-3 + 1e-6 * p.len()).map(|x| x.1)
}
fn plane_sel(s: &Session, pl: &PlaneRef) -> Option<Sel> {
pub(crate) fn plane_sel(s: &Session, pl: &PlaneRef) -> Option<Sel> {
match pl {
PlaneRef::Origin { name } | PlaneRef::Construction { name } => Some(Sel::Plane { name: name.clone() }),
PlaneRef::Custom { plane } | PlaneRef::Face { plane, .. } => face_sel(s, plane.origin),
@@ -2699,7 +2707,7 @@ fn plane_sel(s: &Session, pl: &PlaneRef) -> Option<Sel> {
}
}
fn op_index(o: &Operation) -> usize {
pub(crate) fn op_index(o: &Operation) -> usize {
match o {
Operation::NewBody => 0,
Operation::Join => 1,
@@ -3124,13 +3132,16 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
}
d
}
FeatureKind::Split { body, plane, .. } => {
FeatureKind::Split { body, plane, tool } => {
let mut d = start("solid.split_body")?;
if let Some(inp) = d.inputs.first_mut() {
inp.items = vec![Sel::Body { name: body.clone() }];
}
if let Some(inp) = d.inputs.get_mut(1) {
inp.items = plane_sel(s, plane).into_iter().collect();
inp.items = match tool {
Some(t) => face_sel(s, t.point).into_iter().collect(),
None => plane_sel(s, plane).into_iter().collect(),
};
}
d
}
@@ -3234,7 +3245,9 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
d
}
other => {
let (kind, inputs, extra) = crate::dialogs_sheet::for_feature(app, other).or_else(|| crate::dialogs_plastic::for_feature(app, other))?;
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))?;
let mut d = Dialog::new(kind, inputs);
d.extra = extra;
d
+264
View File
@@ -0,0 +1,264 @@
//! Dialogs for the surface tools and face splitting (#19): Patch, Stitch, Thicken, Extend,
//! Trim and Split Face. Each has a live preview and Edit Feature.
use serde_json::{Map, Value, json};
use solvecraft_engine::Sel;
use solvecraft_engine::doc::expr::Kind as ValueKind;
use solvecraft_engine::doc::{FaceAt, FeatureKind, PlaneRef};
use solvecraft_engine::geom::Vec3;
use crate::SolveApp;
use crate::dialogs::{Kind, OP_LABELS, OPS, combo, field, op_index, row_label};
use crate::selection::{BODIES, EDGES, FACES, PLANES, PROFILES, SelInput};
/// A surface tool dialog's values.
#[derive(Clone, Debug, PartialEq)]
pub enum Surf {
Patch,
Stitch { tolerance: String },
Thicken { thickness: String, symmetric: bool, operation: usize },
Extend { distance: String },
Trim,
SplitFace,
}
impl Surf {
pub fn title(&self) -> &'static str {
match self {
Surf::Patch => "PATCH",
Surf::Stitch { .. } => "STITCH",
Surf::Thicken { .. } => "THICKEN",
Surf::Extend { .. } => "EXTEND",
Surf::Trim => "TRIM",
Surf::SplitFace => "SPLIT FACE",
}
}
/// The value on the canvas.
pub fn primary(&mut self) -> Option<(&'static str, ValueKind, &mut String)> {
Some(match self {
Surf::Thicken { thickness, .. } => ("Thickness", ValueKind::Length, thickness),
Surf::Extend { distance } => ("Distance", ValueKind::Length, distance),
_ => return None,
})
}
}
fn inputs(k: &Surf) -> Vec<SelInput> {
match k {
Surf::Patch => vec![SelInput::new("Boundary", PROFILES | EDGES, true)],
Surf::Stitch { .. } => vec![SelInput::new("Surfaces", BODIES, true)],
Surf::Thicken { .. } => vec![SelInput::new("Surfaces", BODIES, true)],
Surf::Extend { .. } => vec![SelInput::new("Edges", EDGES, true)],
Surf::Trim => vec![
SelInput::new("Surface", BODIES, false),
SelInput::new("Trimming tool", PLANES | FACES, false),
SelInput::new("Side to keep", FACES, false),
],
Surf::SplitFace => vec![SelInput::new("Faces to split", FACES, true), SelInput::new("Splitting tool", PLANES | FACES, false)],
}
}
/// The dialog for a surface command.
pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
let k = match id {
"surface.patch" => Surf::Patch,
"surface.stitch" => Surf::Stitch { tolerance: String::new() },
"surface.thicken" => Surf::Thicken { thickness: "2 mm".into(), symmetric: false, operation: 0 },
"surface.extend" => Surf::Extend { distance: "10 mm".into() },
"surface.trim" => Surf::Trim,
"solid.split_face" => Surf::SplitFace,
_ => return None,
};
let i = inputs(&k);
Some((Kind::Surface(k), i))
}
/// The rows after the inputs; true when Enter was pressed in a field.
pub fn rows(ui: &mut egui::Ui, k: &mut Surf) -> bool {
let mut enter = false;
match k {
Surf::Stitch { tolerance } => {
row_label(ui, "Tolerance");
let r = ui.add(egui::TextEdit::singleline(tolerance).hint_text("tight"));
crate::params_dialog::complete(ui, &r, tolerance);
enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
ui.end_row();
}
Surf::Thicken { thickness, symmetric, operation } => {
row_label(ui, "Thickness");
enter |= field(ui, thickness);
ui.end_row();
row_label(ui, "Direction");
let mut dir = usize::from(*symmetric);
combo(ui, "th_dir", &["One Side", "Symmetric"], &mut dir);
*symmetric = dir == 1;
ui.end_row();
row_label(ui, "Operation");
combo(ui, "th_op", &OP_LABELS, operation);
ui.end_row();
}
Surf::Extend { distance } => {
row_label(ui, "Distance");
enter |= field(ui, distance);
ui.end_row();
}
Surf::Patch | Surf::Trim | Surf::SplitFace => {}
}
enter
}
fn items(inputs: &[SelInput], i: usize) -> &[Sel] {
inputs.get(i).map(|x| x.items.as_slice()).unwrap_or(&[])
}
fn pt(v: Vec3) -> Value {
json!([v.x, v.y, v.z])
}
fn bodies(inputs: &[SelInput], i: usize) -> Vec<String> {
items(inputs, i).iter().filter_map(|x| if let Sel::Body { name } = x { Some(name.clone()) } else { None }).collect()
}
/// 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> {
match sel {
Some(Sel::Plane { name }) => Ok(("plane", json!(name))),
Some(Sel::Face { body, index, point }) => match crate::dialogs::planar_face(&app.session, body, *index) {
Some((_, n)) => Ok(("plane", json!({"origin": pt(*point), "normal": pt(n)}))),
None => Ok(("tool", json!({"body": body, "point": pt(*point)}))),
},
_ => Err("pick a plane or a face as the tool".into()),
}
}
/// The command a surface dialog makes.
pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map<String, Value>) -> Result<Vec<(String, Value)>, String> {
let need = |i: usize, what: &str| if items(inputs, i).is_empty() { Err(format!("select {what} first")) } else { Ok(()) };
let (cmd, mut p) = match k {
Surf::Patch => {
need(0, "a boundary (sketch profiles or a loop of edges)")?;
let profiles: Vec<(u64, usize)> =
items(inputs, 0).iter().filter_map(|x| if let Sel::Profile { sketch, index } = x { Some((*sketch, *index)) } else { None }).collect();
if let Some((sk, _)) = profiles.first() {
let idx: Vec<usize> = profiles.iter().filter(|(s, _)| s == sk).map(|(_, i)| *i).collect();
("surface.patch", json!({"sketch": sk, "profiles": idx}))
} else {
let edges: Vec<Value> =
items(inputs, 0).iter().filter_map(|x| if let Sel::Edge { point, .. } = x { Some(pt(*point)) } else { None }).collect();
let body = items(inputs, 0).iter().find_map(|x| if let Sel::Edge { body, .. } = x { Some(body.clone()) } else { None });
("surface.patch", json!({"edges": edges, "body": body}))
}
}
Surf::Stitch { tolerance } => {
need(0, "the surfaces to stitch")?;
let mut p = json!({ "bodies": bodies(inputs, 0) });
if !tolerance.trim().is_empty() {
p["tolerance"] = json!(tolerance);
}
("surface.stitch", p)
}
Surf::Thicken { thickness, symmetric, operation } => {
need(0, "the surfaces to thicken")?;
(
"surface.thicken",
json!({"bodies": bodies(inputs, 0), "thickness": thickness, "symmetric": symmetric, "operation": OPS.get(*operation).copied().unwrap_or("new")}),
)
}
Surf::Extend { distance } => {
need(0, "the edges to extend")?;
let body =
items(inputs, 0).iter().find_map(|x| if let Sel::Edge { body, .. } = x { Some(body.clone()) } else { None }).unwrap_or_default();
let edges: Vec<Value> =
items(inputs, 0).iter().filter_map(|x| if let Sel::Edge { point, .. } = x { Some(pt(*point)) } else { None }).collect();
("surface.extend", json!({"body": body, "edges": edges, "distance": distance}))
}
Surf::Trim => {
need(0, "the surface to trim")?;
need(1, "the trimming tool")?;
need(2, "the side to keep (a point on it)")?;
let body = bodies(inputs, 0).into_iter().next().unwrap_or_default();
let (key, t) = tool(app, items(inputs, 1).first())?;
let keep = items(inputs, 2)
.first()
.and_then(|x| if let Sel::Face { point, .. } = x { Some(pt(*point)) } else { None })
.ok_or("pick the side to keep")?;
let mut p = json!({"body": body, "keep": keep});
p[key] = t;
("surface.trim", p)
}
Surf::SplitFace => {
need(0, "the faces to split")?;
need(1, "the splitting tool")?;
let faces: Vec<Value> =
items(inputs, 0).iter().filter_map(|x| if let Sel::Face { point, .. } = x { Some(pt(*point)) } else { None }).collect();
let body = items(inputs, 0).iter().find_map(|x| if let Sel::Face { body, .. } = x { Some(body.clone()) } else { None });
let (key, t) = tool(app, items(inputs, 1).first())?;
let mut p = json!({"faces": faces, "body": body});
p[key] = t;
("solid.split_face", p)
}
};
if let Value::Object(m) = &mut p {
for (k, v) in extra {
m.entry(k.clone()).or_insert_with(|| v.clone());
}
}
Ok(vec![(cmd.to_string(), p)])
}
/// A tool as a selection again: a plane, else the face at the stored point.
fn tool_sel(app: &SolveApp, plane: &PlaneRef, tool: Option<&FaceAt>) -> Option<Sel> {
match tool {
Some(t) => crate::dialogs::face_sel(&app.session, t.point),
None => crate::dialogs::plane_sel(&app.session, plane),
}
}
/// The dialog for editing a surface feature.
pub fn for_feature(app: &SolveApp, kind: &FeatureKind) -> Option<(Kind, Vec<SelInput>, Map<String, Value>)> {
let s = &app.session;
let edge = |p: &Vec3| crate::dialogs::edge_sel(s, *p);
let (k, fill): (Surf, Vec<Vec<Sel>>) = match kind {
FeatureKind::Patch { sketch, edges, .. } => {
let mut sels: Vec<Sel> = edges.iter().filter_map(edge).collect();
if let Some(sk) = sketch {
let st = s.world_state();
if let Some(ss) = st.sketch(*sk) {
sels.extend((0..ss.profiles.len()).map(|index| Sel::Profile { sketch: *sk, index }));
}
}
(Surf::Patch, vec![sels])
}
FeatureKind::Stitch { bodies, tolerance } => (
Surf::Stitch { tolerance: if tolerance.trim() == "0" { String::new() } else { tolerance.clone() } },
vec![bodies.iter().map(|n| Sel::Body { name: n.clone() }).collect()],
),
FeatureKind::Thicken { bodies, thickness, symmetric, operation, .. } => (
Surf::Thicken { thickness: thickness.clone(), symmetric: *symmetric, operation: op_index(operation) },
vec![bodies.iter().map(|n| Sel::Body { name: n.clone() }).collect()],
),
FeatureKind::SurfaceExtend { edges, distance, .. } => {
(Surf::Extend { distance: distance.clone() }, vec![edges.iter().filter_map(edge).collect()])
}
FeatureKind::SurfaceTrim { body, plane, tool, keep } => (
Surf::Trim,
vec![
vec![Sel::Body { name: body.clone() }],
tool_sel(app, plane, tool.as_ref()).into_iter().collect(),
crate::dialogs::face_sel(s, *keep).into_iter().collect(),
],
),
FeatureKind::SplitFace { faces, plane, tool, .. } => (
Surf::SplitFace,
vec![faces.iter().filter_map(|p| crate::dialogs::face_sel(s, *p)).collect(), tool_sel(app, plane, tool.as_ref()).into_iter().collect()],
),
_ => return None,
};
let mut ins = inputs(&k);
for (inp, items) in ins.iter_mut().zip(fill) {
inp.items = items;
}
Some((Kind::Surface(k), ins, Map::new()))
}
+19
View File
@@ -768,6 +768,25 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
pen.line(&[(15.0, 15.0), (19.0, 19.0)], a);
pen.line(&[(19.0, 15.0), (15.0, 19.0)], a);
}
"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.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);
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);
}
"extend" => {
pen.line(&[(20.0, 3.0), (20.0, 21.0)], ink);
pen.line(&[(3.0, 12.0), (10.0, 12.0)], ink);
+1
View File
@@ -23,6 +23,7 @@ pub mod dialogs_move;
pub mod dialogs_parts;
pub mod dialogs_plastic;
pub mod dialogs_sheet;
pub mod dialogs_surface;
pub mod dim_view;
pub mod documents;
pub mod drag_snap;
+50
View File
@@ -326,3 +326,53 @@ fn align_snaps_to_centres_and_edge_middles() {
assert_eq!(p["from"]["snap"], json!("face_center"));
assert_eq!(p["to"]["snap"], json!("edge_mid"));
}
#[test]
fn surface_dialogs_make_their_commands() {
// A planar patch of a sketch's rectangle, then thickened; Edit Feature brings both back.
let mut app = app_with(json!([
{"command": "sketch.create", "params": {"plane": "XY"}},
{"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [30, 20]}},
{"command": "sketch.finish", "params": {}}
]));
let sk = app.session.doc.features.last().unwrap().id;
let mut d = start(&mut app, vec![], "surface.patch");
d.inputs[0].items = vec![Sel::Profile { sketch: sk, index: 0 }];
let patch = {
for (id, p) in apply_commands(&app, &d).unwrap() {
app.session.execute(&id, &p).unwrap();
}
app.session.doc.features.last().unwrap().id
};
let surf = app.session.model.state().bodies.last().unwrap().name.clone();
assert!(matches!(crate::dialogs::for_feature(&app, patch, None).unwrap().kind, Kind::Surface(_)));
app.dialog = None;
let mut d = start(&mut app, vec![], "surface.thicken");
d.inputs[0].items = vec![Sel::Body { name: surf }];
let (id, p) = apply_commands(&app, &d).unwrap().remove(0);
assert_eq!(id, "surface.thicken");
app.session.execute(&id, &p).unwrap();
let v = app.session.model.state().bodies.iter().map(|b| b.mesh().measure().volume).fold(0.0, f64::max);
assert!((v - 30.0 * 20.0 * 2.0).abs() < 1.0, "{v}");
}
#[test]
fn split_body_by_a_curved_face_sends_it_as_a_tool() {
let mut app = app_with(json!([
{"command": "solid.box", "params": {"length": 40, "width": 30, "height": 20}},
{"command": "solid.cylinder", "params": {"diameter": 20, "height": 40, "base": [20, 15, -10], "operation": "new"}}
]));
let st = app.session.model.state();
let cyl = st.bodies.iter().find(|b| b.name != "Body1").unwrap();
let m = cyl.mesh();
// A point on the round side.
let p = Vec3::new(30.0, 15.0, 5.0);
let t = m.triangles.iter().position(|t| crate::dialogs::point_tri_dist(p, m.tri(t).unwrap()) < 0.3).unwrap();
let side = Sel::Face { body: cyl.name.clone(), index: m.tri_face[t] as usize, point: p };
drop(st);
let mut d = start(&mut app, vec![], "solid.split_body");
d.inputs[0].items = vec![Sel::Body { name: "Body1".into() }];
d.inputs[1].items = vec![side];
let p = apply_commands(&app, &d).unwrap().remove(0).1;
assert!(p["tool"]["point"].is_array() && p.get("plane").is_none(), "{p}");
}