diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 6ef4a2bb..7f7f00ae 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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, 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, 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 { best.filter(|(d, _)| *d < 1e-3 + 1e-6 * p.len()).map(|x| x.1) } -fn plane_sel(s: &Session, pl: &PlaneRef) -> Option { +pub(crate) fn plane_sel(s: &Session, pl: &PlaneRef) -> Option { 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 { } } -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) -> Option { + 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) -> Option { - 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 diff --git a/crates/ui-egui/src/dialogs_surface.rs b/crates/ui-egui/src/dialogs_surface.rs new file mode 100644 index 00000000..da832ef3 --- /dev/null +++ b/crates/ui-egui/src/dialogs_surface.rs @@ -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 { + 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)> { + 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 { + 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) -> Result, 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 = profiles.iter().filter(|(s, _)| s == sk).map(|(_, i)| *i).collect(); + ("surface.patch", json!({"sketch": sk, "profiles": idx})) + } else { + let edges: Vec = + 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 = + 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 = + 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 { + 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, Map)> { + let s = &app.session; + let edge = |p: &Vec3| crate::dialogs::edge_sel(s, *p); + let (k, fill): (Surf, Vec>) = match kind { + FeatureKind::Patch { sketch, edges, .. } => { + let mut sels: Vec = 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())) +} diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 66da116d..1d2ac375 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -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); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bb0df76b..968c4488 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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; diff --git a/crates/ui-egui/src/preselect_tests.rs b/crates/ui-egui/src/preselect_tests.rs index 3b5b0df6..8a79bbb1 100644 --- a/crates/ui-egui/src/preselect_tests.rs +++ b/crates/ui-egui/src/preselect_tests.rs @@ -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}"); +}