From 90ce5261b3c63b40d82cf532665783623cf0c05d Mon Sep 17 00:00:00 2001 From: CypherNaugh_0x <9931495+CypherNaught-0x@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:40:07 +0200 Subject: [PATCH 01/15] =?UTF-8?q?An=20angle=20dimension=20between=20two=20?= =?UTF-8?q?lines=20takes=20the=20reading=20nearest=20its=20value=20and=20r?= =?UTF-8?q?eports=20degrees:=20without=20text=5Fat=20the=20angle=20was=20m?= =?UTF-8?q?easured=20between=20the=20lines'=20directions,=20so=20a=20riser?= =?UTF-8?q?=20pointing=20up=20and=20an=20outlet=20pointing=20down-left=20(?= =?UTF-8?q?meeting=20at=2030=C2=B0,=20directions=20150=C2=B0=20apart)=20as?= =?UTF-8?q?ked=20for=2030=C2=B0=20swung=20the=20outlet=20round=20by=20120?= =?UTF-8?q?=C2=B0,=20which=20the=20fillets=20between=20them=20could=20not?= =?UTF-8?q?=20follow,=20and=20the=20dimension=20was=20refused=20as=20over-?= =?UTF-8?q?constraining=20with=20a=20degree=20of=20freedom=20left.=20Two?= =?UTF-8?q?=20lines=20make=20two=20angles,=20=CE=B8=20and=20180=C2=B0=20?= =?UTF-8?q?=E2=88=92=20=CE=B8;=20the=20dimension=20now=20takes=20the=20one?= =?UTF-8?q?=20nearer=20the=20value=20(the=20acute=20one=20without=20a=20va?= =?UTF-8?q?lue),=20and=20text=5Fat=20still=20picks=20a=20sector.=20sketch.?= =?UTF-8?q?dimension=20reports=20measured=20in=20the=20dimension's=20unit?= =?UTF-8?q?=20(deg=20for=20angles,=20mm=20for=20lengths)=20with=20a=20unit?= =?UTF-8?q?=20field,=20instead=20of=20radians=20for=20angles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/engine/src/cmd/sketch.rs | 37 ++++++++--- .../src/cmd/sketch_constraints_tests.rs | 62 +++++++++++++++++++ 2 files changed, 90 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/sketch.rs b/crates/engine/src/cmd/sketch.rs index b054b667..865ea5b3 100644 --- a/crates/engine/src/cmd/sketch.rs +++ b/crates/engine/src/cmd/sketch.rs @@ -1,7 +1,7 @@ //! Sketch commands: create/edit/finish sketches, draw curves, constraints and dimensions. use serde_json::{Value, json}; -use solvecraft_doc::{FeatureKind, PlaneRef}; +use solvecraft_doc::{FeatureKind, PlaneRef, expr::Kind}; use solvecraft_geom::{Plane, Vec2, Vec3}; use solvecraft_sketch::{ConstraintKind, CurveKind, Hold, Sketch, SolveReport, solve, solve_holding}; @@ -94,7 +94,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .params(concat!("p0, p1: overall ends, width", connect_doc!())), CommandSpec::new("sketch.point", "Point", draw_point).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).params(concat!("point: [x,y], id?", connect_doc!())), CommandSpec::new("sketch.dimension", "Sketch Dimension", dimension).at("SKETCH", "CREATE").icon("dimension").key("D").enabled(in_sketch).params( - "entities: [refs], type?: auto|distance|horizontal|vertical|length|radius|diameter|angle, value?: number or expression (default: current)", + "entities: [refs], type?: auto|distance|horizontal|vertical|length|radius|diameter|angle, value?: number or expression (default: current); text_at?: [x, y]. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. Returns measured in unit (mm or deg)", ), CommandSpec::new("sketch.constraint.horizontal_vertical", "Horizontal/Vertical", c_horizontal_vertical) .at("SKETCH", "CONSTRAINTS") @@ -1332,7 +1332,9 @@ fn dimension(s: &mut Session, p: &Value) -> Result { }; let is_line = |c: usize| matches!(sk.curves.get(c).map(|c| &c.kind), Some(CurveKind::Line { .. })); let is_circle = |c: usize| matches!(sk.curves.get(c).map(|c| &c.kind), Some(CurveKind::Circle { .. })); - let (k, cur): (ConstraintKind, f64) = match (e0, e1) { + // The other reading of an angle between two lines (see below). + let mut alt: Option<(ConstraintKind, f64)> = None; + let (mut k, mut cur): (ConstraintKind, f64) = match (e0, e1) { ((None, Some(c)), None) if is_line(c) => { let (a, b) = line_pts(sk, c).unwrap_or_default(); match ty.as_str() { @@ -1409,7 +1411,14 @@ fn dimension(s: &mut Session, p: &Value) -> Result { ang = -ang; std::mem::swap(&mut ka, &mut kb); } - (ConstraintKind::Angle { a: ka, b: kb, value: ang, flip: false }, ang) + // Two lines make two angles, θ and 180° − θ, whichever way they were + // drawn: the acute one unless the value asks for the other. + let direct = (ConstraintKind::Angle { a: ka, b: kb, value: ang, flip: false }, ang); + let supplement = + (ConstraintKind::Angle { a: kb, b: ka, value: std::f64::consts::PI - ang, flip: true }, std::f64::consts::PI - ang); + let (acute, obtuse) = if ang <= std::f64::consts::FRAC_PI_2 { (direct, supplement) } else { (supplement, direct) }; + alt = Some(obtuse); + acute } } } @@ -1426,7 +1435,7 @@ fn dimension(s: &mut Session, p: &Value) -> Result { c.driven = true; } before = None; - return Ok((String::new(), name, cur)); + return Ok((String::new(), name, measured(is_angle, cur))); } let (unit, default_expr) = if is_angle { ("deg", format!("{} deg", round6(cur.to_degrees()))) } else { ("mm", format!("{} mm", round6(cur))) }; @@ -1437,20 +1446,30 @@ fn dimension(s: &mut Session, p: &Value) -> Result { if let Some(err) = errs.get(&pname) { return Err(bad(cmd, format!("value: {err}"))); } - let _ = vals; + // An angle between two lines dimensions whichever of its two readings is nearer the value. + if let (Some((ka, ca)), Some(want)) = (alt, vals.get(&pname).and_then(|v| v.to_kind(Kind::Angle).ok())) + && (want - ca).abs() < (want - cur).abs() + { + (k, cur) = (ka, ca); + } let name = k.name(); let id = sk.add_constraint(k, Some(pname.clone()))?; place_text(sk, &id, text_at); - Ok((pname, name, cur)) + Ok((pname, name, measured(is_angle, cur))) })?; if driven { - return Ok(json!({"param": Value::Null, "driven": true, "type": kind_name, "measured": current, "sketch": info})); + return Ok(json!({"param": Value::Null, "driven": true, "type": kind_name, "measured": current.0, "unit": current.1, "sketch": info})); } if reject_redundant(before, &info, cmd).is_err() { return Err(bad(cmd, "that dimension would over-constrain the sketch; add it as a driven (reference) dimension with driven: true")); } let v = s.doc.param(¶m).map(|p| p.expr.clone()).unwrap_or_default(); - Ok(json!({"param": param, "type": kind_name, "expression": v, "measured": current, "sketch": info})) + Ok(json!({"param": param, "type": kind_name, "expression": v, "measured": current.0, "unit": current.1, "sketch": info})) +} + +/// A dimension's measured value as reported, in the unit its expression is written in. +fn measured(is_angle: bool, v: f64) -> (f64, &'static str) { + if is_angle { (round6(v.to_degrees()), "deg") } else { (round6(v), "mm") } } /// Keep a dimension's text where it was placed (`at`, sketch coordinates), relative to the diff --git a/crates/engine/src/cmd/sketch_constraints_tests.rs b/crates/engine/src/cmd/sketch_constraints_tests.rs index 0c5edd7e..8623eb77 100644 --- a/crates/engine/src/cmd/sketch_constraints_tests.rs +++ b/crates/engine/src/cmd/sketch_constraints_tests.rs @@ -201,3 +201,65 @@ fn drawing_onto_a_curve_keeps_the_point_on_it() { let cc = sk.center(sk.curve_index(&c).unwrap()).unwrap(); assert!((st.dist(cc) - 8.0).abs() < 1e-7, "{st:?} {cc:?}"); } + +/// A faucet spout path: a vertical riser, a horizontal top and an outlet sloping down, joined +/// by fillets. The riser points up and the outlet down-left, so their directions are 150° apart +/// while the lines meet at 30°. +fn spout() -> Session { + let mut s = new_sketch(); + run(&mut s, "sketch.line", json!({"points": [[795, 955], [795, 1068], [689, 1068], [661, 1020]], "ids": ["l1", "l2", "l3"]})); + run(&mut s, "sketch.fillet", json!({"point": "l1.end", "radius": 50})); + run(&mut s, "sketch.fillet", json!({"point": "l2.end", "radius": 35})); + run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l1", "mode": "vertical"})); + run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l2", "mode": "horizontal"})); + run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l1.start"], "type": "horizontal", "value": 795})); + run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l1.start"], "type": "vertical", "value": 955})); + run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l2.start"], "type": "vertical", "value": 1068})); + run(&mut s, "sketch.dimension", json!({"entities": ["l3.end", "l1.start"], "type": "horizontal", "value": 134})); + let d = run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l3.end"], "type": "vertical", "value": 1020})); + assert_eq!(d["sketch"]["dof"], 1, "{d}"); + s +} + +/// The angle (degrees, 0..=90) between two lines of the active sketch. +fn line_angle(s: &Session, a: &str, b: &str) -> f64 { + let sk = sketch(s); + let dir = |l: &str| { + let c = sk.curve_index(l).unwrap(); + let (p, q) = crate::cmd::sketch::line_pts(&sk, c).unwrap(); + q - p + }; + let (u, v) = (dir(a), dir(b)); + let t = u.cross(v).abs().atan2(u.dot(v)).to_degrees(); + t.min(180.0 - t) +} + +#[test] +fn an_angle_between_lines_takes_the_reading_nearest_its_value_and_reports_degrees() { + // The riser and the outlet are not adjacent and meet at 30°; their directions are 150° + // apart. Asking for 30° used to swing the outlet round and was refused as over-constraining. + for ents in [["l1", "l3"], ["l3", "l1"]] { + let mut s = spout(); + let d = run(&mut s, "sketch.dimension", json!({"entities": ents, "type": "angle", "value": "30 deg"})); + assert_eq!(d["sketch"]["dof"], 0, "{d}"); + assert_eq!(d["unit"], "deg", "{d}"); + assert!((d["measured"].as_f64().unwrap() - 30.3).abs() < 0.1, "{d}"); + assert!((line_angle(&s, "l1", "l3") - 30.0).abs() < 1e-6); + } + // The other reading still works, and the adjacent top line measures 60° the same way. + let mut s = spout(); + let d = run(&mut s, "sketch.dimension", json!({"entities": ["l1", "l3"], "value": "150 deg"})); + assert_eq!(d["sketch"]["dof"], 0, "{d}"); + assert!((line_angle(&s, "l1", "l3") - 30.0).abs() < 1e-6); + let mut s = spout(); + let d = run(&mut s, "sketch.dimension", json!({"entities": ["l2", "l3"], "value": "60 deg"})); + assert!((d["measured"].as_f64().unwrap() - 59.74).abs() < 0.01, "{d}"); + // Without a value the acute angle is kept as it is. + let mut s = spout(); + let d = run(&mut s, "sketch.dimension", json!({"entities": ["l1", "l3"]})); + let e = d["expression"].as_str().unwrap(); + assert!(e.ends_with(" deg") && (e.trim_end_matches(" deg").parse::().unwrap() - 30.3).abs() < 0.1, "{d}"); + // Lengths report millimetres. + let d = run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "driven": true})); + assert_eq!(d["unit"], "mm", "{d}"); +} From 49bd89de13d420e16109376f2d84e06b6c3bbf90 Mon Sep 17 00:00:00 2001 From: CypherNaugh_0x <9931495+CypherNaught-0x@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:00:32 +0200 Subject: [PATCH 02/15] Parameter and feature edits report the features they restored from the evaluation cache: setting a parameter back (or a timeline.edit back) reused the earlier results for the same inputs, so the geometry changed back but recomputed said 0, which read as "nothing rebuilt". The evaluator now counts cache hits as last_restored, and parameters.change, timeline.edit, timeline.redefine and solid.press_pull on a fillet or chamfer face report it as restored next to recomputed (params docs, the MCP set_parameter description and docs/mcp.md say what each counts). Tests: setting a parameter back and a feature edit back report restored, in the evaluator and through the commands --- crates/doc/src/eval.rs | 7 +++++++ crates/doc/src/tests.rs | 20 ++++++++++++++++++++ crates/engine/src/cmd/edit.rs | 10 ++++++---- crates/engine/src/cmd/features.rs | 2 +- crates/engine/src/cmd/parameters.rs | 4 +++- crates/engine/src/cmd/parameters_tests.rs | 19 +++++++++++++++++++ crates/mcp/src/tools.rs | 3 ++- docs/mcp.md | 2 +- 8 files changed, 59 insertions(+), 8 deletions(-) diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index fcf41553..a7229d47 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -377,6 +377,10 @@ pub struct Model { empty: Arc, /// Features recomputed by the last `evaluate`. pub last_recomputed: usize, + /// Features whose result the last `evaluate` took from the cache instead of recomputing: the + /// same feature on the same inputs was evaluated before (an edit set back, undo, redo, a + /// configuration row). Their geometry did change since the previous evaluation. + pub last_restored: usize, /// Results by inputs, shared with this model's copies. pub cache: Arc>, /// Evaluate everything from scratch (no reuse; tests compare the two). @@ -505,6 +509,7 @@ impl Model { let mut out: Vec = Vec::with_capacity(doc.features.len()); let mut reuse = !self.no_cache; let mut recomputed = 0; + let mut restored = 0; let marker = doc.marker.unwrap_or(usize::MAX); let ctx = context_digest(doc, &vals); for (i, f) in doc.features.iter().enumerate() { @@ -569,6 +574,7 @@ impl Model { if !self.no_cache && let Some(c) = self.cache.lock().ok().and_then(|mut m| m.get(key)) { + restored += 1; if c.error.is_none() { state = c.state.clone(); } @@ -652,6 +658,7 @@ impl Model { } self.results = out; self.last_recomputed = recomputed; + self.last_restored = restored; } } diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index a11dd964..b74f6da1 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -81,6 +81,26 @@ fn parametric_plate_reevaluates() { assert!(rel(measure(&m.state().bodies[0].body).unwrap().volume, 50.0 * 30.0 * 10.0) < 1e-9); } +/// Setting a parameter back takes the earlier results from the cache: nothing is recomputed, but +/// the evaluation reports the features it restored (their geometry did change back). +#[test] +fn set_back_reports_restored_features() { + let (mut doc, _) = plate_doc(); + let mut m = Model::new(); + m.evaluate(&doc); + assert_eq!((m.last_recomputed, m.last_restored), (2, 0)); + doc.set_param("width", "50 mm", None, None).unwrap(); + m.evaluate(&doc); + assert_eq!((m.last_recomputed, m.last_restored), (2, 0)); + doc.set_param("width", "40 mm", None, None).unwrap(); + m.evaluate(&doc); + assert_eq!((m.last_recomputed, m.last_restored), (0, 2)); + assert!(rel(measure(&m.state().bodies[0].body).unwrap().volume, 24000.0) < 1e-9); + // No change: nothing recomputed and nothing restored. + m.evaluate(&doc); + assert_eq!((m.last_recomputed, m.last_restored), (0, 0)); +} + #[test] fn box_fillet_cut_timeline() { let (mut doc, _) = plate_doc(); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 64bd563e..08eb8695 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -28,8 +28,10 @@ pub static COMMANDS: &[CommandSpec] = &[ .noundo() .params("feature: id|name → features deleted with it, and features that would fail without it"), CommandSpec::new("timeline.suppress", "Suppress Feature", suppress).params("feature: id|name, suppressed?: bool (default toggles)"), - CommandSpec::new("timeline.edit", "Edit Feature", edit_feature) - .params("feature: id|name, set: {fields to change, e.g. {\"extent\": {\"distance\": \"30\"}}}"), + CommandSpec::new("timeline.edit", "Edit Feature", edit_feature).params( + "feature: id|name, set: {fields to change, e.g. {\"extent\": {\"distance\": \"30\"}}} → recomputed: features rebuilt, \ + restored: features whose earlier result for the same inputs was reused", + ), CommandSpec::new("select.set", "Select", select_set) .noundo() .params("items: [{type: body|edge|face|feature|sketch_curve|sketch_point|profile, …}], add?: bool"), @@ -204,7 +206,7 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result { if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) { return Err(EngineError::Other(e)); } - Ok(json!({"feature": id, "recomputed": s.model.last_recomputed})) + Ok(json!({"feature": id, "recomputed": s.model.last_recomputed, "restored": s.model.last_restored})) } fn select_set(s: &mut Session, p: &Value) -> Result { @@ -307,7 +309,7 @@ fn redefine(s: &mut Session, p: &Value) -> Result { if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) { return Err(EngineError::Other(e)); } - Ok(json!({"feature": id, "recomputed": s.model.last_recomputed})) + Ok(json!({"feature": id, "recomputed": s.model.last_recomputed, "restored": s.model.last_restored})) } fn dependents(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index dd7890be..fbdb8848 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -581,7 +581,7 @@ fn press_pull(s: &mut Session, p: &Value) -> Result { let new = format!("({old}) + ({distance})"); let r = super::find_command("timeline.edit").ok_or_else(|| bad(cmd, "timeline.edit"))?; let out = (r.run)(s, &json!({"feature": f.id, "set": {key: new}}))?; - return Ok(json!({"feature": f.id, "edited": f.name, key: new, "recomputed": out.get("recomputed")})); + return Ok(json!({"feature": f.id, "edited": f.name, key: new, "recomputed": out.get("recomputed"), "restored": out.get("restored")})); } // Another curved face moves along its normal. let r = super::find_command("solid.offset_face").ok_or_else(|| bad(cmd, "solid.offset_face"))?; diff --git a/crates/engine/src/cmd/parameters.rs b/crates/engine/src/cmd/parameters.rs index 82b8759d..5437627f 100644 --- a/crates/engine/src/cmd/parameters.rs +++ b/crates/engine/src/cmd/parameters.rs @@ -12,7 +12,8 @@ use crate::{EngineError, Result, Session}; pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("parameters.change", "Change Parameters", change_param).at("SOLID", "MODIFY").icon("params").params( "name, expression (number or text), unit?: mm|cm|m|in|ft|deg|rad|\"\" (new parameters default to their value's unit), comment?, \ - favorite?: bool, new_name?: rename (references follow); or delete: name", + favorite?: bool, new_name?: rename (references follow); or delete: name → recomputed: features rebuilt, \ + restored: features whose earlier result for the same inputs was reused (e.g. a value set back)", ), CommandSpec::new("parameters.list", "List Parameters", list).noundo().params( "favorites?: bool (only favourites), filter?: all|user|sketch|feature|favorites → parameters (name, expression, unit, value in its unit, \ @@ -107,6 +108,7 @@ fn change_param(s: &mut Session, p: &Value) -> Result { out["value"] = json!(vals.get(&name).map(|v| v.v)); out["display_value"] = json!(s.doc.param_display_value(&vals, &name)); out["recomputed"] = json!(s.model.last_recomputed); + out["restored"] = json!(s.model.last_restored); out["errors"] = json!(failing(s)); Ok(out) } diff --git a/crates/engine/src/cmd/parameters_tests.rs b/crates/engine/src/cmd/parameters_tests.rs index 18983408..db8ae158 100644 --- a/crates/engine/src/cmd/parameters_tests.rs +++ b/crates/engine/src/cmd/parameters_tests.rs @@ -67,6 +67,25 @@ fn width_follows_an_expression() { assert!(e.contains("expected a length, got an angle"), "{e}"); } +/// Setting a parameter back rebuilds nothing (the earlier results are reused) and says so: the +/// features it restored are reported next to `recomputed`, so `recomputed: 0` is not mistaken +/// for "nothing changed". timeline.edit reports the same. +#[test] +fn setting_a_parameter_back_reports_restored_features() { + let mut s = Session::default(); + block(&mut s); + let r = run(&mut s, "parameters.change", json!({"name": "len", "expression": "100 mm"})); + assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(2), Some(0)), "{r}"); + let r = run(&mut s, "parameters.change", json!({"name": "len", "expression": "60 mm"})); + assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(0), Some(2)), "{r}"); + assert!(rel(volume(&mut s), 40.0 * 30.0 * 10.0) < 1e-9); + let r = run(&mut s, "timeline.edit", json!({"feature": "Extrude1", "set": {"extent": {"distance": "20"}}})); + assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(1), Some(0)), "{r}"); + let r = run(&mut s, "timeline.edit", json!({"feature": "Extrude1", "set": {"extent": {"distance": "10"}}})); + assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(0), Some(1)), "{r}"); + assert!(rel(volume(&mut s), 40.0 * 30.0 * 10.0) < 1e-9); +} + #[test] fn feature_inputs_are_named_and_editable() { let mut s = Session::default(); diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index c307b284..0d1e993a 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -129,7 +129,8 @@ pub fn tool_definitions() -> Vec { "set_parameter", "Set a parameter", "Create or change a user parameter and re-evaluate the timeline. `value` is a number (mm) or an expression \ - (\"40 mm\", \"width * 2\", \"30 deg\"). Returns the new value, the features recomputed and any features that now fail.", + (\"40 mm\", \"width * 2\", \"30 deg\"). Returns the new value, the features recomputed (`recomputed`), the features whose earlier \ + result for the same inputs was reused instead (`restored`, e.g. after setting a value back) and any features that now fail.", obj( json!({ "name": string("Parameter name"), diff --git a/docs/mcp.md b/docs/mcp.md index c6a996c0..3aec985e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -54,7 +54,7 @@ Claude Desktop (`claude_desktop_config.json`), bridged to a running app: | `inspect_design` | `measure?` (default true), `sketches?` (default true) | Parameters, timeline (type, suppressed, rolled back, error, warning, timing), bodies (volume, area, centre of mass, bbox, face/edge/vertex counts, visible), sketches (curves, points, constraints, profiles, DOF, solve status, visible), selection, undo depth. Hide helper sketches, bodies or construction planes with `execute` `browser.visibility` (`{items, visible}` or `{folder, visible}`). | | `measure` | `body?` | Volume (mm³), area (mm²), centre of mass, bbox and topology counts per body, with totals. | | `body_topology` | `body` | Edges (index, midpoint, length, ends) and faces (index, area, centroid, normal). An edge midpoint is the `[x,y,z]` that picks that edge for fillet and chamfer. | -| `set_parameter` | `name`, `value` (number or expression), `unit?`, `comment?` | Create or change a user parameter and recompute; reports features that now fail. | +| `set_parameter` | `name`, `value` (number or expression), `unit?`, `comment?` | Create or change a user parameter and recompute; reports `recomputed` (features rebuilt), `restored` (features whose earlier result for the same inputs was reused, e.g. a value set back) and features that now fail. | | `screenshot` | `view?` (iso, top, front, back, bottom, left, right, home, fit), `path?`, `width?`, `height?`, `source?` (window, model) | A PNG image content block. Headless: CPU render fitted to the model. Bridged: the app window (default) or a model render with the app's camera. | | `export` | `path`, `format?` (step, 3mf, stl, stla, obj), `bodies?` | STEP, 3MF or mesh export. | | `undo`, `redo` | — | Undo / redo one change. | From d6cdec40dacfa02b4cdac965e60de5d5c341b84e Mon Sep 17 00:00:00 2001 From: bherbruck Date: Sat, 10 Oct 2026 15:05:27 -0400 Subject: [PATCH 03/15] Fix modelling regressions and reconcile WIP UI work --- ROADMAP.md | 11 +- apps/solvecraft-cli/src/recipe.rs | 2 +- apps/solvecraft/src/main.rs | 18 ++ crates/doc/src/canvas.rs | 23 +- crates/doc/src/document.rs | 1 + crates/doc/src/eval.rs | 96 +++++++- crates/doc/src/eval_more.rs | 7 +- crates/doc/src/format.rs | 55 ++++- crates/doc/src/lib.rs | 2 +- crates/doc/src/naming.rs | 6 + crates/doc/src/project.rs | 15 +- crates/engine/src/cmd/browser.rs | 4 +- crates/engine/src/cmd/parts.rs | 2 +- crates/engine/src/cmd/section.rs | 2 +- crates/engine/src/cmd/sketch.rs | 11 +- crates/engine/src/cmd/sketch_canvas.rs | 3 + crates/engine/src/cmd/sketch_modify.rs | 14 +- crates/engine/src/cmd/sketch_project.rs | 14 +- crates/engine/src/frames.rs | 43 +++- crates/engine/src/frames_tests.rs | 103 ++++++++ crates/engine/src/tests.rs | 19 +- crates/geom/src/plane.rs | 4 +- crates/geom/src/tests.rs | 4 +- crates/kernel/src/iges_in/tests.rs | 3 +- crates/kernel/src/lib.rs | 2 +- crates/kernel/src/ops.rs | 11 +- crates/kernel/src/prism.rs | 131 +++++++++- crates/kernel/src/splitface.rs | 2 +- crates/kernel/src/tests.rs | 40 +++- crates/sketch/src/lib.rs | 2 + crates/sketch/src/link.rs | 78 +++++- crates/sketch/src/model.rs | 3 + crates/sketch/src/offset.rs | 116 +++++++++ crates/sketch/src/wire.rs | 2 +- crates/ui-egui/src/browser.rs | 50 ++-- crates/ui-egui/src/context_menu.rs | 18 +- crates/ui-egui/src/dialogs.rs | 48 +++- crates/ui-egui/src/dialogs_assembly.rs | 225 +++++++++++++----- crates/ui-egui/src/dialogs_assembly_tests.rs | 38 +++ crates/ui-egui/src/dialogs_motion.rs | 135 ++++++++++- crates/ui-egui/src/documents.rs | 24 +- crates/ui-egui/src/home.rs | 72 ++++-- crates/ui-egui/src/lib.rs | 24 +- crates/ui-egui/src/menu_tests.rs | 6 +- crates/ui-egui/src/ref_images.rs | 4 +- crates/ui-egui/src/scenario.rs | 9 + crates/ui-egui/src/toolbar.rs | 54 ++++- crates/ui-egui/src/tools.rs | 36 +-- crates/ui-egui/src/viewport.rs | 1 + crates/ui-egui/tests/scenarios.rs | 95 ++++++++ .../acceptance_lid_lip_after_resize.json | 3 - .../acceptance_sensor_enclosure.json | 64 +++++ .../acceptance_snap_fit_after_resize.json | 3 - .../scenarios/s01_box_fillet_hole_shell.json | 1 + .../scenarios/s06_edit_early_feature.json | 1 + .../scenarios/s07_components_joint_drive.json | 33 ++- .../scenarios/s10_parameters_drive_model.json | 1 + .../scenarios/s11_sketch_constraints.json | 26 +- .../s20_section_with_joint_drive.json | 31 ++- .../tests/scenarios/s23_configurations.json | 1 + .../scenarios/s26_drive_joint_typed.json | 31 ++- .../s32_cut_in_active_component.json | 6 +- .../tests/scenarios/s37_browser_chevrons.json | 32 ++- ...3_root_sketch_on_moved_component_face.json | 103 ++++++++ .../sketch_on_moved_component_face.json | 3 - .../tests/scenarios/view_cube_y_up.json | 4 +- docs/wip-reconciliation.md | 22 ++ 67 files changed, 1793 insertions(+), 260 deletions(-) create mode 100644 crates/sketch/src/offset.rs create mode 100644 crates/ui-egui/tests/scenarios/s43_root_sketch_on_moved_component_face.json create mode 100644 docs/wip-reconciliation.md diff --git a/ROADMAP.md b/ROADMAP.md index 862bb90c..3a7621ea 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # SolveCraft roadmap -Status as of **2026-10-09**. SolveCraft targets Fusion-style parametric modelling (sketch → +Status as of **2026-10-10**. SolveCraft targets Fusion-style parametric modelling (sketch → features → parametric timeline) as an open, pure-Rust, agent-drivable application. ## At a glance @@ -10,12 +10,19 @@ features → parametric timeline) as an open, pure-Rust, agent-drivable applicat | Where are we? | **M0, M1, M13 done; M2, M5–M7, M9, M10, M14 in progress.** Sketch with solver and the full SKETCH create/modify set; extrude, revolve (two sides, symmetric, to object), fillet/chamfer (variable radius, chord length, a radius per edge, corner patches), holes, threads (cosmetic and modelled), patterns, mirror, shell (inside/outside/both), draft, loft, sweep, coil, split body and split face, delete face, construction planes, axes and points; persistent naming of faces and edges; parameters and configurations; components, joints, motion studies, exploded views; sheet metal and a plastic enclosure subset; STEP/IGES/3MF/STL/OBJ/DXF; autosave and crash recovery; desktop and browser (wasm) app, CLI, MCP server, oracle harness. | | Command parity (in scope) | **328 / 541 (61%)** in-scope commands; SOLID + SKETCH **185 / 291 (64%)** — [docs/parity.md](docs/parity.md) | | Fusion oracle | **76 / 76 parts match** — [docs/oracle.md](docs/oracle.md) | -| Tests | 596 (41 of them UI scenarios): solver, profiles, kernel booleans/blends/measures, expressions, timeline, persistent naming, file formats and migrations, assemblies, sheet metal, engine end-to-end, hostile-input fuzz over every command | +| Tests | Solver, profiles, kernel booleans/blends/measures, expressions, timeline, persistent naming, file formats and migrations, assemblies, sheet metal, engine end-to-end, hostile-input fuzz over every command and headless UI scenarios. `cargo xtask ci` reports the current passing counts. | | Gates | `cargo xtask ci`: fmt, clippy -D warnings, tests, asset attribution, layering, wasm32 build — green | | Time to a useful alpha (M0–M6) | ≈ **82 agent hours** remain (sum of the M1–M6 estimates below) | ## Scope (owner decision, 2026-10-07) +The issue batch adds preservation of blind pockets in through cuts, associative projected +offsets and plastic face placements, XZ (+Y normal) with a v6 migration preserving older +designs, interactive angle placement, file-picker sketch imports, Home/recent controls, +toolbar overflow, and the remaining component-frame and assembly WIP work. The enclosure +acceptance scenarios now include board resizes; STEP reopening is automated. Inspection in +Fusion remains an external acceptance step. See [WIP reconciliation](docs/wip-reconciliation.md). + In scope, in priority order: 1. SOLID and SKETCH (now). 2. Parameters / functional constraints (done: see `parameters.*`, `expr.evaluate`; unit-aware, diff --git a/apps/solvecraft-cli/src/recipe.rs b/apps/solvecraft-cli/src/recipe.rs index 7a51d47f..6669eaf6 100644 --- a/apps/solvecraft-cli/src/recipe.rs +++ b/apps/solvecraft-cli/src/recipe.rs @@ -10,7 +10,7 @@ fn plane_param(pl: &Value) -> Value { let origin_zero = pl.get("origin").and_then(Value::as_array).is_none_or(|o| o.iter().all(|x| x.as_f64().unwrap_or(0.0).abs() < 1e-12)); let std = match r.to_ascii_uppercase().as_str() { "XY" => Some(([1.0, 0.0, 0.0], [0.0, 1.0, 0.0])), - "XZ" => Some(([1.0, 0.0, 0.0], [0.0, 0.0, 1.0])), + "XZ" => Some(([1.0, 0.0, 0.0], [0.0, 0.0, -1.0])), "YZ" => Some(([0.0, 1.0, 0.0], [0.0, 0.0, 1.0])), _ => None, }; diff --git a/apps/solvecraft/src/main.rs b/apps/solvecraft/src/main.rs index 4dcdcc4d..a3a70162 100644 --- a/apps/solvecraft/src/main.rs +++ b/apps/solvecraft/src/main.rs @@ -61,6 +61,7 @@ fn services() -> Services { .add_filter("STEP", &["step", "stp", "STEP", "STP"]) .add_filter("IGES", &["igs", "iges", "IGS", "IGES"]) .add_filter("Mesh (3MF, STL)", &["3mf", "3MF", "stl", "STL"]) + .add_filter("Sketch drawings (DXF, SVG)", &["dxf", "DXF", "svg", "SVG"]) .add_filter("All files", &["*"]) .pick_file() .map(|p| p.to_string_lossy().to_string()) @@ -69,6 +70,23 @@ fn services() -> Services { rfd::FileDialog::new().set_file_name(name).add_filter("File", exts).save_file().map(|p| p.to_string_lossy().to_string()) })), graphics_path: graphics_path().map(|p| p.to_string_lossy().to_string()), + show_in_folder: Some(Box::new(|path| { + let folder = std::path::Path::new(path).parent().unwrap_or_else(|| std::path::Path::new(".")); + let mut command = std::process::Command::new(if cfg!(target_os = "windows") { + "explorer" + } else if cfg!(target_os = "macos") { + "open" + } else { + "xdg-open" + }); + command.arg(folder); + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + command.creation_flags(0x08000000); + } + let _ = command.spawn(); + })), } } diff --git a/crates/doc/src/canvas.rs b/crates/doc/src/canvas.rs index a9c07f93..2c7cb9cc 100644 --- a/crates/doc/src/canvas.rs +++ b/crates/doc/src/canvas.rs @@ -34,6 +34,14 @@ pub struct Canvas { pub flip: bool, #[serde(default = "yes")] pub visible: bool, + /// The component it belongs to (its plane is in that component's frame, so it moves with + /// the component's occurrence); 0 = the root. + #[serde(default, skip_serializing_if = "is_root")] + pub component: u64, +} + +fn is_root(c: &u64) -> bool { + *c == 0 } fn half() -> f64 { @@ -57,7 +65,7 @@ impl Canvas { let f = if self.flip { -1.0 } else { 1.0 }; [rot(-hw * f, -hh), rot(hw * f, -hh), rot(hw * f, hh), rot(-hw * f, hh)] } - /// Corners in world coordinates. + /// Corners on `plane` (in whatever frame the plane is given). pub fn world_corners(&self, plane: &Plane) -> [Vec3; 4] { self.corners().map(|p| plane.to_world(p)) } @@ -66,6 +74,19 @@ impl Canvas { } } +impl crate::Document { + /// A canvas's plane in the world: resolved in its component's frame, then placed where the + /// component's occurrence puts it. + pub fn canvas_plane(&self, vals: &std::collections::BTreeMap, c: &Canvas) -> crate::Result { + let p = self.resolve_plane(vals, &c.plane, 0)?; + let m = self.component_transform(c.component); + if crate::is_identity(&m) { + return Ok(p); + } + Ok(Plane::new(crate::apply_point(&m, p.origin), crate::apply_vector(&m, p.x), crate::apply_vector(&m, p.y)).unwrap_or(p)) + } +} + /// Image kind and pixel size from the file header (PNG or JPEG). pub fn image_info(b: &[u8]) -> Option<(&'static str, [u32; 2])> { if b.len() > 24 && b.starts_with(b"\x89PNG\r\n\x1a\n") { diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 8b0e80ea..c7f70e3a 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -1115,6 +1115,7 @@ impl FeatureKind { FeatureKind::Extrude { extent, .. } => extent.to.iter_mut().chain(extent.from.iter_mut()).collect(), FeatureKind::Revolve { to: Some(p), .. } => vec![p], FeatureKind::Thread { face, .. } => vec![face], + FeatureKind::Boss { position, .. } | FeatureKind::SnapFit { position, .. } | FeatureKind::Rest { position, .. } => vec![position], _ => Vec::new(), } } diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index a7229d47..8cd6a94d 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -471,6 +471,22 @@ fn cache_key(f: &Feature, chain_fp: u64, local: u64, ctx: u64, state: &ModelStat h.finish() } +/// Where components are placed, for a sketch that sees other components' geometry (on a face, +/// or with projections): it is resolved in its own component's view of them. +fn placement_digest(doc: &Document, f: &Feature) -> u64 { + let FeatureKind::Sketch { plane, sketch } = &f.kind else { return 0 }; + if doc.occurrences.is_empty() || (!matches!(plane, PlaneRef::Face { .. }) && sketch.links.is_empty()) { + return 0; + } + let mut h = std::collections::hash_map::DefaultHasher::new(); + for o in &doc.occurrences { + o.id.hash(&mut h); + o.transform.iter().flatten().for_each(|x| x.to_bits().hash(&mut h)); + } + serde_json::to_string(&doc.body_offsets).unwrap_or_default().hash(&mut h); + h.finish() +} + fn now() -> web_time::Instant { web_time::Instant::now() } @@ -514,7 +530,7 @@ impl Model { let ctx = context_digest(doc, &vals); for (i, f) in doc.features.iter().enumerate() { let rolled_back = i >= marker; - let mut fp = fingerprint(prev_fp, f, &vals, rolled_back); + let mut fp = fingerprint(prev_fp, f, &vals, rolled_back) ^ placement_digest(doc, f); // Sheet metal features also depend on the rules (and the parameters they use). if f.kind.is_sheet() { let mut h = std::collections::hash_map::DefaultHasher::new(); @@ -570,7 +586,7 @@ impl Model { continue; } // The same feature on the same inputs: its earlier result. - let key = cache_key(f, fp, fingerprint(0, f, &vals, false), ctx, &state); + let key = cache_key(f, fp, fingerprint(0, f, &vals, false) ^ placement_digest(doc, f), ctx, &state); if !self.no_cache && let Some(c) = self.cache.lock().ok().and_then(|mut m| m.get(key)) { @@ -1405,6 +1421,20 @@ fn feature_tools(vals: &BTreeMap, f: &Feature, st: &ModelState) - } let mut tools = Vec::new(); for r in ®ions { + if *operation == Operation::Join { + let exact = kernel::extrude(&ss.plane, std::slice::from_ref(r), lo + off, hi + off)?; + if exact.iter().all(|tool| { + st.bodies + .iter() + .filter(|b| targets.is_empty() || targets.contains(&b.name)) + .any(|b| kernel::can_join_touching_faces(&b.body, tool)) + }) { + // Preserve a whole touching face: extending into a chamfer replaces + // the sewable seam with a coincident curved intersection. + tools.extend(exact); + continue; + } + } let (l2, h2) = extend_for_coplanar(st, &ss.plane, r, lo + off, hi + off, *operation, targets); let probe = |p: Vec2| (1..4).map(|k| ss.plane.to_world(p) + n * (lo + off + (hi - lo) * k as f64 / 4.0)).collect::>(); let r = grow_profile_for_coplanar(st, r, *operation, targets, &probe); @@ -2288,17 +2318,25 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: | FeatureKind::Align { .. } | FeatureKind::Remove { .. } => more::eval(doc, vals, f, st), FeatureKind::Sketch { plane, sketch } => { + let fst = state_in_frame(doc, st, f.component); let (plane, face_warning) = match plane { - PlaneRef::Face { plane: picked, at, name } => face_plane(st, picked, *at, name.as_deref()), - other => (doc.resolve_plane_in(vals, other, 0, Some(st))?, None), + PlaneRef::Face { plane: picked, at, name } => face_plane(&fst, picked, *at, name.as_deref()), + other => (doc.resolve_plane_in(vals, other, 0, Some(&fst))?, None), }; let mut sk = sketch.clone(); - let mut warns = crate::project::refresh_links(doc, vals, st, &plane, &mut sk); + let mut warns = crate::project::refresh_links(doc, vals, &fst, &plane, &mut sk); + drop(fst); + sk.refresh_offsets()?; if let Some(w) = face_warning { warns.insert(0, w); } doc.apply_dimension_values(vals, &mut sk)?; - let report = solve(&mut sk); + let mut report = solve(&mut sk); + if !sk.offsets.is_empty() { + // Source dimensions may have moved curves during the solve. + sk.refresh_offsets()?; + report = solve(&mut sk); + } if !report.ok() { warns.insert(0, format!("the sketch constraints conflict ({})", report.failing.join(", "))); } @@ -2818,6 +2856,52 @@ fn unique_body_name(st: &ModelState, base: &str) -> String { (2..).map(|i| format!("{base} ({i})")).find(|n| st.body(n).is_none()).unwrap_or_else(|| base.to_string()) } +/// The model as a component sees it: bodies and sketches of components placed differently from +/// it are moved into its frame (through both placements), so a sketch on another component's +/// face finds the face where it is shown and projects its edges there. The model itself when +/// every placement agrees (the usual case). +pub fn state_in_frame<'a>(doc: &Document, st: &'a ModelState, comp: u64) -> std::borrow::Cow<'a, ModelState> { + use std::borrow::Cow; + if doc.occurrences.is_empty() && doc.body_offsets.is_empty() { + return Cow::Borrowed(st); + } + let Some(inv) = crate::mat_inverse(&doc.component_transform(comp)) else { return Cow::Borrowed(st) }; + let comp_of_feature = |id: u64| doc.feature(id).map(|f| f.component).unwrap_or(0); + let rel = |c: u64, body: Option<&str>| { + let m = crate::mat_mul(&inv, &doc.component_transform(c)); + match body.and_then(|b| doc.body_offsets.get(b)) { + Some(o) => crate::mat_mul(&m, o), + None => m, + } + }; + let body_m: Vec = st.bodies.iter().map(|b| rel(doc.body_component(&b.name, b.feature), Some(&b.name))).collect(); + let sketch_m: Vec = st.sketches.iter().map(|s| rel(comp_of_feature(s.feature), None)).collect(); + if body_m.iter().chain(&sketch_m).all(crate::is_identity) { + return Cow::Borrowed(st); + } + let mut out = st.clone(); + for (b, m) in out.bodies.iter_mut().zip(&body_m) { + if !crate::is_identity(m) + && let Ok(t) = kernel::transform_matrix(&b.body, *m) + { + // Moving a body keeps its faces (and their order): it keeps their names. + let names = crate::naming::face_names(b); + let mut nb = ModelBody::new(b.name.clone(), t, b.feature); + nb.component = b.component; + nb.names = crate::naming::NameCell::known(names); + *b = nb; + } + } + for (s, m) in out.sketches.iter_mut().zip(&sketch_m) { + if !crate::is_identity(m) + && let Some(np) = Plane::new(crate::apply_point(m, s.plane.origin), crate::apply_vector(m, s.plane.x), crate::apply_vector(m, s.plane.y)) + { + s.plane = np; + } + } + Cow::Owned(out) +} + /// The model placed in the world: each component's bodies, sketches and threads through every /// occurrence of it (bodies of the first placement keep their names; further instances get the /// occurrence path in brackets). Without components this is the model itself. diff --git a/crates/doc/src/eval_more.rs b/crates/doc/src/eval_more.rs index 9006f74e..84b30e53 100644 --- a/crates/doc/src/eval_more.rs +++ b/crates/doc/src/eval_more.rs @@ -613,7 +613,12 @@ fn face_loops(mesh: &solvecraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3, le /// The body a placed plastic feature joins: by name, else the one under the point. fn body_under(st: &ModelState, body: &Option, p: Vec3) -> Result { let i = body_at(st, body, &[p])?; - st.bodies.get(i).map(|b| b.name.clone()).ok_or_else(|| DocError::Invalid("there is no body".into())) + let b = st.bodies.get(i).ok_or_else(|| DocError::Invalid("there is no body".into()))?; + let tolerance = (b.body.size() * 1e-5).max(1e-4); + if crate::project::nearest_face(&b.mesh(), p).is_none_or(|(_, _, distance)| distance > tolerance) { + return Err(DocError::Invalid("the plastic feature's placement is no longer on its body face".into())); + } + Ok(b.name.clone()) } /// Axes with z along `z` (and x along `x` when given). diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs index abdf81c9..cf0328ec 100644 --- a/crates/doc/src/format.rs +++ b/crates/doc/src/format.rs @@ -18,13 +18,15 @@ //! ["*"]` (every body), which is what they did, so those designs keep their shape. //! - 4: joint origins mate by default (B's z against A's, as Fusion does); `flip` now means //! "don't mate". Older joints keep their placement: their `flip` is inverted. +//! - 6: XZ origin planes use sketch y = -Z and normal +Y. Older XZ references become +//! explicit planes retaining their previous axes, including offset-plane expressions. use serde_json::Value; use crate::{DocError, Document, Result}; /// The format this build writes. -pub const FORMAT: u32 = 5; +pub const FORMAT: u32 = 6; pub fn format_string(n: u32) -> String { format!("solvecraft/{n}") @@ -37,7 +39,30 @@ pub fn version_of(s: &str) -> Option { /// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`. type JsonStep = fn(&mut Value) -> Result<()>; -const JSON_STEPS: [JsonStep; 4] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5]; +const JSON_STEPS: [JsonStep; 5] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6]; + +/// Old XZ references retain their exact frame, including nested offset and angled planes. +/// New origin references use +Y; freezing the old frame avoids changing sketch coordinates, +/// dimensions, profiles, extrude directions or parameter expressions in saved designs. +fn v5_to_v6(v: &mut Value) -> Result<()> { + fn preserve(v: &mut Value) { + match v { + Value::Object(o) => { + let old_xz = o.get("type").and_then(Value::as_str) == Some("origin") + && o.get("name").and_then(Value::as_str).is_some_and(|n| n.eq_ignore_ascii_case("XZ") || n.eq_ignore_ascii_case("FRONT")); + if old_xz { + *v = serde_json::json!({"type": "custom", "plane": {"origin": [0, 0, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, 1]}}); + } else { + o.values_mut().for_each(preserve); + } + } + Value::Array(a) => a.iter_mut().for_each(preserve), + _ => {} + } + } + preserve(v); + Ok(()) +} /// Features in designs with components keep reaching every body. fn v4_to_v5(v: &mut Value) -> Result<()> { @@ -198,6 +223,32 @@ mod tests { assert_eq!(back, d); } + #[test] + fn old_xz_and_nested_offsets_keep_their_frame() { + let mut doc = Document::new("old XZ"); + let id = doc + .add_feature( + crate::FeatureKind::ConstructionPlane { + plane: crate::PlaneRef::Offset { base: Box::new(crate::PlaneRef::Origin { name: "XZ".into() }), distance: "7 mm".into() }, + }, + None, + ) + .unwrap(); + let mut json: Value = serde_json::from_str(&doc.to_json()).unwrap(); + json["format"] = Value::from("solvecraft/5"); + let migrated = read(&json.to_string()).unwrap(); + let crate::FeatureKind::ConstructionPlane { plane } = &migrated.feature(id).unwrap().kind else { panic!("plane") }; + let (values, errors) = migrated.param_values(); + assert!(errors.is_empty()); + let p = migrated.resolve_plane(&values, plane, 0).unwrap(); + assert_eq!(p.y, solvecraft_geom::Vec3::Z); + assert_eq!(p.normal(), -solvecraft_geom::Vec3::Y); + assert_eq!(p.origin, solvecraft_geom::Vec3::new(0.0, -7.0, 0.0)); + assert_eq!(read(&migrated.to_json()).unwrap(), migrated); + let fresh = doc.resolve_plane(&values, &crate::PlaneRef::Origin { name: "XZ".into() }, 0).unwrap(); + assert_eq!(fresh.normal(), solvecraft_geom::Vec3::Y); + } + #[test] fn older_upgraded_newer_refused_others_rejected() { let mut v: Value = serde_json::from_str(&Document::new("A").to_json()).unwrap(); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 10856e30..9f920212 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -29,7 +29,7 @@ pub use assembly::{IDENTITY, Mat, Occurrence, apply_point, apply_vector, is_iden pub use document::*; pub use eval::{ EdgeRef, FeatureResult, Model, ModelBody, ModelState, SolvedSketch, ThreadInfo, edge_names_for, edge_refs, parse_metric_thread, pattern_matrices, - world_state, + state_in_frame, world_state, }; pub use params::{ParamRow, ParamUser, cycles}; pub use solvecraft_kernel as kernel; diff --git a/crates/doc/src/naming.rs b/crates/doc/src/naming.rs index 72c60881..430c81fa 100644 --- a/crates/doc/src/naming.rs +++ b/crates/doc/src/naming.rs @@ -34,6 +34,12 @@ impl NameCell { pub fn new(origin: Option>) -> Self { NameCell { origin, names: Arc::new(OnceLock::new()) } } + /// Names already known (a moved copy of a body keeps its faces' names). + pub fn known(names: Arc>) -> Self { + let cell = OnceLock::new(); + let _ = cell.set(names); + NameCell { origin: None, names: Arc::new(cell) } + } } /// Deeper chains than this are named by position only (bounded work and stack). diff --git a/crates/doc/src/project.rs b/crates/doc/src/project.rs index d61a79fd..6e42aa42 100644 --- a/crates/doc/src/project.rs +++ b/crates/doc/src/project.rs @@ -273,7 +273,20 @@ pub fn refresh_links(doc: &Document, vals: &BTreeMap, st: &ModelS let mut warn = Vec::new(); let links = sk.links.clone(); for l in &links { - match resolve(doc, vals, st, plane, l.kind, &l.source) { + let mut source = l.source.clone(); + if let (Some(name), LinkSource::Face { at, .. }) = (&l.face_name, &mut source) { + if let Some((p, split)) = crate::naming::point_on_face(st, name, *at) { + *at = p; + if split { + warn.push(format!("projected face `{name}` was split; using the nearest piece")); + } + } else { + sk.set_link_lost(&l.id, true); + warn.push(format!("projected face `{name}` no longer exists")); + continue; + } + } + match resolve(doc, vals, st, plane, l.kind, &source) { Ok(r) => { sk.set_link_source(&l.id, r.source); let _ = sk.set_link_wires(&l.id, &r.wires); diff --git a/crates/engine/src/cmd/browser.rs b/crates/engine/src/cmd/browser.rs index 5bb71272..1740ac04 100644 --- a/crates/engine/src/cmd/browser.rs +++ b/crates/engine/src/cmd/browser.rs @@ -495,7 +495,9 @@ mod tests { assert!((zmax(&mut s) - 5.0).abs() < 1e-6); run(&mut s, "sketch.redefine", json!({"sketch": sk, "plane": "XZ"})); let z = zmax(&mut s); - assert!(z > 9.0, "the profile stands up on XZ: {z}"); + assert!(z.abs() < 1e-6, "positive sketch Y on XZ extends toward -Z: {z}"); + let ymin = run(&mut s, "inspect.measure", json!({}))["bodies"][0]["bbox"]["min"][2].as_f64().unwrap(); + assert!((ymin + 20.0).abs() < 1e-6); assert!(s.execute("sketch.redefine", &json!({"sketch": sk, "plane": "nope"})).is_err()); assert!(s.execute("sketch.redefine", &json!({"sketch": 999, "plane": "XY"})).is_err()); } diff --git a/crates/engine/src/cmd/parts.rs b/crates/engine/src/cmd/parts.rs index f8964ab6..0a5d4401 100644 --- a/crates/engine/src/cmd/parts.rs +++ b/crates/engine/src/cmd/parts.rs @@ -231,7 +231,7 @@ fn cyl(s: &mut Session, d: f64, z0: f64, z1: f64, op: &str) -> Result<()> { /// A solid of revolution about z from a closed (r, z) outline, as one body or joined. fn revolve(s: &mut Session, pts: &[(f64, f64)], op: &str) -> Result<()> { run(s, "sketch.create", json!({"plane": "XZ"}))?; - let points: Vec<[f64; 2]> = pts.iter().map(|(r, z)| [*r, *z]).collect(); + let points: Vec<[f64; 2]> = pts.iter().map(|(r, z)| [*r, -*z]).collect(); run(s, "sketch.line", json!({"points": points, "closed": true}))?; run(s, "sketch.finish", json!({}))?; run(s, "solid.revolve", json!({"axis": "y", "operation": op}))?; diff --git a/crates/engine/src/cmd/section.rs b/crates/engine/src/cmd/section.rs index fcc48cf7..4486817a 100644 --- a/crates/engine/src/cmd/section.rs +++ b/crates/engine/src/cmd/section.rs @@ -10,7 +10,7 @@ use crate::{Result, Session}; pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("inspect.section", "Section Analysis", section).at("SOLID", "INSPECT").icon("section").noundo().params( - "plane: XY|XZ|YZ | {origin, normal}; offset?: expr along the plane's normal (XY: +Z, XZ: -Y, YZ: +X); \ + "plane: XY|XZ|YZ | {origin, normal}; offset?: expr along the plane's normal (XY: +Z, XZ: +Y, YZ: +X); \ at?: expr, the cut's world coordinate on the named plane's axis (XY: Z, XZ: Y, YZ: X), instead of offset; \ flip?: bool; or clear: true. Returns the cut's origin and normal, and for a named plane its axis and at", )]; diff --git a/crates/engine/src/cmd/sketch.rs b/crates/engine/src/cmd/sketch.rs index 865ea5b3..e76bd360 100644 --- a/crates/engine/src/cmd/sketch.rs +++ b/crates/engine/src/cmd/sketch.rs @@ -359,8 +359,10 @@ pub(super) fn plane_ref(s: &Session, p: &Value, cmd: &str) -> Result { } Some(v @ Value::Object(o)) => { if let Some(fp) = o.get("face").and_then(vec3) { - // A planar face of a body at this point. - let st = s.model.state(); + // A planar face of a body at this point, as the active component sees the model + // (another component's face where it is placed). + let model = s.model.state(); + let st = solvecraft_doc::state_in_frame(&s.doc, &model, s.active_component); let mut found = None; let mut face_name = None; for b in &st.bodies { @@ -406,7 +408,10 @@ fn face_contains(m: &solvecraft_geom::Mesh, fi: usize, p: Vec3, tol: f64) -> boo let s1 = (b - a).cross(p - a).dot(n); let s2 = (c - b).cross(p - b).dot(n); let s3 = (a - c).cross(p - c).dot(n); - (s1 >= 0.0 && s2 >= 0.0 && s3 >= 0.0) || (s1 <= 0.0 && s2 <= 0.0 && s3 <= 0.0) + (s1 >= 0.0 && s2 >= 0.0 && s3 >= 0.0) + || (s1 <= 0.0 && s2 <= 0.0 && s3 <= 0.0) + // Exact curved boundaries can sit just outside their tessellated chords. + || p.dist_to_segment(a, b).min(p.dist_to_segment(b, c)).min(p.dist_to_segment(c, a)) <= tol * 0.1 }) } diff --git a/crates/engine/src/cmd/sketch_canvas.rs b/crates/engine/src/cmd/sketch_canvas.rs index 5bb84a89..8c6061f0 100644 --- a/crates/engine/src/cmd/sketch_canvas.rs +++ b/crates/engine/src/cmd/sketch_canvas.rs @@ -57,6 +57,8 @@ fn make(s: &mut Session, p: &Value, cmd: &str, decal: bool) -> Result { return Err(bad(cmd, "`width` must be positive")); } let opacity = num(p, "opacity").unwrap_or(if decal { 1.0 } else { 0.5 }).clamp(0.0, 1.0); + // It belongs to the active component (its plane is in that frame) and moves with it. + let component = s.active_component; let doc = s.doc_mut(); let id = doc.canvases.iter().map(|c| c.id).max().unwrap_or(0) + 1; let base = if decal { "Decal" } else { "Canvas" }; @@ -74,6 +76,7 @@ fn make(s: &mut Session, p: &Value, cmd: &str, decal: bool) -> Result { opacity, flip: bool_(p, "flip").unwrap_or(false), visible: true, + component, }); Ok(json!({"canvas": id, "name": name, "pixels": pixels})) } diff --git a/crates/engine/src/cmd/sketch_modify.rs b/crates/engine/src/cmd/sketch_modify.rs index 76c710e2..14d4b739 100644 --- a/crates/engine/src/cmd/sketch_modify.rs +++ b/crates/engine/src/cmd/sketch_modify.rs @@ -813,6 +813,12 @@ fn offset(s: &mut Session, p: &Value) -> Result { } let ci = sk.add_circle(cc, nr, Some(c), None)?; made.push(ci); + sk.offsets.push(solvecraft_sketch::OffsetChain { + sources: vec![(ids[0].clone(), true)], + outputs: ids_of(sk, &made), + distance: r - nr, + closed: true, + }); return Ok((ids_of(sk, &made), cons)); } let pool: Vec = if use_chain { @@ -916,7 +922,13 @@ fn offset(s: &mut Session, p: &Value) -> Result { } } } - Ok((ids_of(sk, &made), cons)) + let outputs = ids_of(sk, &made); + if pieces.iter().all(|p| !matches!(p, Piece::Free(_))) { + let sources = order.iter().filter_map(|(i, forward)| sk.curves.get(*i).map(|c| (c.id.clone(), *forward))).collect(); + sk.offsets.push(solvecraft_sketch::OffsetChain { sources, outputs: outputs.clone(), distance: d, closed }); + sk.refresh_offsets()?; + } + Ok((outputs, cons)) })?; Ok(json!({"curves": out.0, "constraints": out.1, "sketch": info})) } diff --git a/crates/engine/src/cmd/sketch_project.rs b/crates/engine/src/cmd/sketch_project.rs index c2778ced..b7ee7586 100644 --- a/crates/engine/src/cmd/sketch_project.rs +++ b/crates/engine/src/cmd/sketch_project.rs @@ -140,8 +140,20 @@ pub(super) fn add_link(s: &Session, doc: &Document, id: u64, sk: &mut Sketch, ki let plane = sketch_plane(doc, id)?; let (vals, _) = doc.param_values(); let st = s.model.state_before(id); + // The model as the sketch's component sees it. + let comp = doc.feature(id).map(|f| f.component).unwrap_or(0); + let st = solvecraft_doc::state_in_frame(doc, &st, comp); let r = resolve(doc, &vals, &st, &plane, kind, &src)?; - Ok(sk.add_link_with_wires(kind, r.source, &r.geom, &r.wires)?) + let name = if let LinkSource::Face { at, .. } = &r.source { + solvecraft_doc::naming::face_names_at(&st, &[*at]).into_iter().next().filter(|n| !n.is_empty()) + } else { + None + }; + let link = sk.add_link_with_wires(kind, r.source, &r.geom, &r.wires)?; + if let Some(l) = sk.links.iter_mut().find(|l| l.id == link) { + l.face_name = name; + } + Ok(link) } fn link_json(sk: &Sketch, link: &str) -> Value { diff --git a/crates/engine/src/frames.rs b/crates/engine/src/frames.rs index a4b2de07..b089af96 100644 --- a/crates/engine/src/frames.rs +++ b/crates/engine/src/frames.rs @@ -30,6 +30,12 @@ enum K { Plane, /// A body name, or a list of them. Body, + /// A point (or a list of picks) for the sketch being made or edited: into that sketch's + /// component frame, whichever component the geometry under it belongs to (a sketch in the + /// root on a moved component's face finds the face where it is shown). + Here, + /// A plane object for a new sketch or canvas: into the active component's frame. + HerePlane, } use K::*; @@ -56,11 +62,12 @@ fn table(id: &str) -> &'static [(&'static str, K)] { "plastic.lip" | "sheet.convert" => &[("face", Pick), ("body", Body)], "sheet.flange" | "sheet.hem" => &[("edges", Picks), ("body", Body)], "sheet.fold" => &[("points", Picks), ("fixed", Pick), ("body", Body)], - "sketch.create" | "sketch.redefine" => &[("plane", Plane)], - "canvas.decal" => &[("face", Pick)], - "sketch.intersect" => &[("refs", Picks)], - "sketch.project_to_surface" | "sketch.isoparametric_curve" => &[("face", Pick), ("body", Body)], - "sketch.intersection_curve" => &[("a", Picks), ("b", Picks)], + "sketch.create" | "sketch.redefine" => &[("plane", HerePlane)], + "canvas.decal" => &[("face", Here)], + "canvas.insert" => &[("plane", HerePlane), ("at", Here)], + "sketch.project" | "sketch.intersect" | "sketch.include_3d" => &[("refs", Here), ("ref", Here)], + "sketch.project_to_surface" | "sketch.isoparametric_curve" => &[("face", Here)], + "sketch.intersection_curve" => &[("a", Here), ("b", Here)], "inspect.curvature_comb" | "inspect.minimum_radius" | "inspect.isocurve" => &[("edges", Picks), ("faces", Picks)], "parts.insert" | "parts.fastener" => &[("at", Pick), ("point", Make), ("direction", Dir)], "appearance.assign" => &[("faces", Picks), ("bodies", Body), ("body", Body)], @@ -75,6 +82,8 @@ fn table(id: &str) -> &'static [(&'static str, K)] { struct Frames { bodies: Vec<(String, String, Mat)>, active: Mat, + /// The frame of the sketch being edited (its component), else the active component's. + sketch: Mat, } fn frames(s: &Session) -> Frames { @@ -104,7 +113,8 @@ fn frames(s: &Session) -> Frames { (world, own, m) }) .collect(); - Frames { bodies, active: doc.component_transform(s.active_component) } + let sketch_comp = s.active_sketch.and_then(|id| doc.feature(id)).map(|f| f.component).unwrap_or(s.active_component); + Frames { bodies, active: doc.component_transform(s.active_component), sketch: doc.component_transform(sketch_comp) } } /// Does any placement move anything (so the world differs from the components' frames)? @@ -171,12 +181,14 @@ struct Mapper<'a> { /// The frame of the command's first pick (its directions turn with it). first: Option, changed: bool, + /// Picks go into the sketch's frame (`Here`), not the frame of the body under them. + here: bool, } impl Mapper<'_> { fn pick(&mut self, v: &Value, body: Option<&str>) -> Option { let p = vec3(v)?; - let m = frame_at(self.s, &self.f, p, body).unwrap_or(self.f.active); + let m = if self.here { self.f.sketch } else { frame_at(self.s, &self.f, p, body).unwrap_or(self.f.active) }; self.first.get_or_insert(m); self.local_point(&m, p) } @@ -325,14 +337,14 @@ pub fn to_local(s: &Session, id: &str, params: &Value) -> Option { return None; } let obj = params.as_object()?; - let mut m = Mapper { s, f: frames(s), first: None, changed: false }; + let mut m = Mapper { s, f: frames(s), first: None, changed: false, here: false }; let body = obj.get("body").and_then(Value::as_str).map(str::to_string); let mut out: Map = obj.clone(); // Picks first: their frame turns the directions. for pass in [true, false] { for (k, kind) in keys { let Some(v) = obj.get(*k) else { continue }; - let is_pick = matches!(kind, Pick | Picks | Plane); + let is_pick = matches!(kind, Pick | Picks | Plane | Here | HerePlane); if is_pick != pass { continue; } @@ -343,6 +355,19 @@ pub fn to_local(s: &Session, id: &str, params: &Value) -> Option { Dir => m.dir(v), Plane => m.plane(v), Body => m.body(v), + Here | HerePlane => { + // A new sketch or canvas goes into the active component. + let keep = m.f.sketch; + if *kind == HerePlane { + m.f.sketch = m.f.active; + } + m.here = true; + // Body names stay: the sketch's view of the model keeps them. + let r = if *kind == HerePlane { m.plane(v) } else { m.picks(v, None) }; + m.here = false; + m.f.sketch = keep; + r + } }; if let Some(n) = new { out.insert((*k).to_string(), n); diff --git a/crates/engine/src/frames_tests.rs b/crates/engine/src/frames_tests.rs index df64a15f..751efdf1 100644 --- a/crates/engine/src/frames_tests.rs +++ b/crates/engine/src/frames_tests.rs @@ -144,3 +144,106 @@ fn sketch_on_a_face_follows_a_moved_body() { let c = sketch_point_world(&mut s, "OnTop", "p1"); assert!((c.z - 25.0).abs() < 1e-6, "{c:?}"); } + +/// A 1 x 1 PNG for canvases. +const PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="; + +/// Canvases belong to the active component and move with its occurrence; one put on a moved +/// component's face from the root sits on the face (and stays the root's). +#[test] +fn canvases_move_with_their_component() { + let mut s = moved_part(); + let part = s.active_component; + assert_ne!(part, 0); + run(&mut s, "canvas.insert", json!({"data": PNG, "plane": "XY", "name": "Plan"})); + // From the root, on the moved block's top face (world). + run(&mut s, "component.activate", json!({"component": "root"})); + run(&mut s, "canvas.insert", json!({"data": PNG, "plane": {"face": [65, 10, 10]}, "name": "Lid", "width": 10})); + run(&mut s, "canvas.insert", json!({"data": PNG, "plane": "XZ", "name": "Root"})); + let (vals, _) = s.doc.param_values(); + let world = |s: &Session, name: &str| { + let c = s.doc.canvases.iter().find(|c| c.name == name).unwrap(); + (c.component, s.doc.canvas_plane(&vals, c).unwrap()) + }; + let (c, p) = world(&s, "Plan"); + assert_eq!(c, part); + assert!(near(p.origin, Vec3::new(50.0, 0.0, 0.0)), "{:?}", p.origin); + let (c, p) = world(&s, "Lid"); + assert_eq!(c, 0, "the active component's"); + let lid = s.doc.canvases.iter().find(|c| c.name == "Lid").unwrap(); + let centre = p.to_world(lid.center); + assert!(near(centre, Vec3::new(65.0, 10.0, 10.0)), "{centre:?}"); + assert_eq!(world(&s, "Root").0, 0); + // Moving the occurrence carries its canvases, not the root's. + run(&mut s, "occurrence.move", json!({"occurrence": "Part:1", "translate": [0, 40, 0]})); + let (vals, _) = s.doc.param_values(); + let plan = s.doc.canvases.iter().find(|c| c.name == "Plan").unwrap(); + assert!(near(s.doc.canvas_plane(&vals, plan).unwrap().origin, Vec3::new(50.0, 40.0, 0.0))); + let lid = s.doc.canvases.iter().find(|c| c.name == "Lid").unwrap(); + let centre = s.doc.canvas_plane(&vals, lid).unwrap().to_world(lid.center); + assert!(near(centre, Vec3::new(65.0, 10.0, 10.0)), "{centre:?}"); + let root = s.doc.canvases.iter().find(|c| c.name == "Root").unwrap(); + assert!(near(s.doc.canvas_plane(&vals, root).unwrap().origin, Vec3::ZERO)); + // The component survives a save and reopen. + let back = solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap(); + assert_eq!(back.canvases, s.doc.canvases); +} + +/// World points of a sketch's curves' points (sorted). +fn sketch_world_points(s: &mut Session, sketch: &str) -> Vec { + let si = run(s, "sketch.inspect", json!({ "sketch": sketch })); + si["points"] + .as_array() + .unwrap() + .iter() + .map(|p| { + let w = &p["world"]; + Vec3::new(w[0].as_f64().unwrap(), w[1].as_f64().unwrap(), w[2].as_f64().unwrap()) + }) + .collect() +} + +/// A block "C" (40 x 30 x 20) in its own component, the root active again. +fn block_in_component() -> Session { + let mut s = Session::default(); + run(&mut s, "component.create", json!({"name": "C"})); + run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20})); + run(&mut s, "component.activate", json!({"component": "root"})); + s +} + +/// The user's "big square": a sketch made in the root on a moved component's face lies on the +/// face where it is shown, its projected loop on the face's edges (moved and turned), and it +/// follows when the occurrence moves on. +#[test] +fn root_sketch_on_a_moved_components_face() { + let mut s = block_in_component(); + run(&mut s, "occurrence.move", json!({"occurrence": "C:1", "translate": [100, 0, 0]})); + let r = run(&mut s, "sketch.create", json!({"plane": {"face": [120, 15, 20]}, "name": "Top"})); + assert!(!r["projected"].is_null(), "the face's edges are projected: {r}"); + run(&mut s, "sketch.finish", json!({})); + let pts = sketch_world_points(&mut s, "Top"); + assert!(pts.len() >= 4, "{pts:?}"); + // Every point but the sketch's origin (the model origin on the face plane). + for p in pts.iter().filter(|p| !near(**p, Vec3::new(0.0, 0.0, 20.0))) { + assert!((p.z - 20.0).abs() < 1e-6 && p.x > 100.0 - 1e-6 && p.x < 140.0 + 1e-6 && p.y > -1e-6 && p.y < 30.0 + 1e-6, "{p:?}"); + } + for corner in [Vec3::new(100.0, 0.0, 20.0), Vec3::new(140.0, 30.0, 20.0)] { + assert!(pts.iter().any(|p| near(*p, corner)), "{corner:?} in {pts:?}"); + } + assert_eq!(s.doc.find_feature("Top").unwrap().component, 0, "the sketch is the root's"); + + // Turned 90 degrees about Z (around the world origin): the top face is now x -30..0, y 0..40. + let mut s = block_in_component(); + run(&mut s, "occurrence.move", json!({"occurrence": "C:1", "axis": [0, 0, 1], "angle": "90 deg", "origin": [0, 0, 0]})); + run(&mut s, "sketch.create", json!({"plane": {"face": [-15, 20, 20]}, "name": "Top"})); + run(&mut s, "sketch.finish", json!({})); + let pts = sketch_world_points(&mut s, "Top"); + for corner in [Vec3::new(0.0, 0.0, 20.0), Vec3::new(-30.0, 40.0, 20.0)] { + assert!(pts.iter().any(|p| near(*p, corner)), "{corner:?} in {pts:?}"); + } + // Projected geometry stays on the face when the occurrence moves on (the link re-resolves). + run(&mut s, "occurrence.move", json!({"occurrence": "C:1", "translate": [0, 0, 5]})); + let pts = sketch_world_points(&mut s, "Top"); + assert!(pts.iter().any(|p| near(*p, Vec3::new(-30.0, 40.0, 25.0))), "{pts:?}"); +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 0311b032..3c4a6714 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -20,6 +20,21 @@ fn rel(a: f64, b: f64) -> f64 { (a - b).abs() / b.abs().max(1e-12) } +#[test] +fn join_extrude_from_a_chamfered_top_face() { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY"})); + run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 10})); + run(&mut s, "solid.extrude", json!({"distance": 20})); + run(&mut s, "solid.chamfer", json!({"edges": [[10, 0, 20]], "distance": 2})); + let base = volume(&mut s); + run(&mut s, "sketch.create", json!({"plane": {"face": [0, 0, 20]}})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance": 5, "operation": "join"})); + assert_eq!(s.model.state().bodies.len(), 1); + assert!(rel(volume(&mut s), base + PI * 64.0 * 5.0) < 5e-4); +} + mod component_frames; mod robustness; @@ -650,10 +665,10 @@ fn section_analysis() { assert!(s.execute("inspect.section", &json!({"plane": {"normal": [0, 0, 0]}})).is_err()); run(&mut s, "inspect.section", json!({"clear": true})); assert!(s.section.is_none()); - // XZ's normal is -Y: an offset moves the cut to -Y, `at` places it at a world Y, and the + // XZ's normal is +Y: an offset moves the cut to +Y, `at` places it at a world Y, and the // result says where the cut landed either way. let r = run(&mut s, "inspect.section", json!({"plane": "XZ", "offset": 10})); - assert_eq!((r["section"]["axis"].as_str(), r["section"]["at"].as_f64()), (Some("Y"), Some(-10.0)), "{r}"); + assert_eq!((r["section"]["axis"].as_str(), r["section"]["at"].as_f64()), (Some("Y"), Some(10.0)), "{r}"); for (plane, at, flip) in [("XZ", -282.5, false), ("XZ", 7.0, true), ("XY", 3.0, false), ("YZ", -4.0, true)] { let r = run(&mut s, "inspect.section", json!({"plane": plane, "at": at, "flip": flip})); assert_eq!(r["section"]["at"].as_f64(), Some(at), "{plane} {r}"); diff --git a/crates/geom/src/plane.rs b/crates/geom/src/plane.rs index d644e390..ee51a010 100644 --- a/crates/geom/src/plane.rs +++ b/crates/geom/src/plane.rs @@ -17,8 +17,8 @@ pub struct Plane { impl Plane { /// Top: sketch x = +X, y = +Y, normal +Z. pub const XY: Plane = Plane { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::Y }; - /// Front: sketch x = +X, y = +Z, normal −Y. - pub const XZ: Plane = Plane { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::Z }; + /// Front: sketch x = +X, y = −Z, normal +Y. + pub const XZ: Plane = Plane { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::new(0.0, 0.0, -1.0) }; /// Right: sketch x = +Y, y = +Z, normal +X. pub const YZ: Plane = Plane { origin: Vec3::ZERO, x: Vec3::Y, y: Vec3::Z }; diff --git a/crates/geom/src/tests.rs b/crates/geom/src/tests.rs index 5fe4e25b..af1b6f68 100644 --- a/crates/geom/src/tests.rs +++ b/crates/geom/src/tests.rs @@ -28,10 +28,10 @@ fn raycast_hits_front_face() { #[test] fn planes() { assert_eq!(Plane::XY.normal(), Vec3::Z); - assert_eq!(Plane::XZ.normal(), Vec3::new(0.0, -1.0, 0.0)); + assert_eq!(Plane::XZ.normal(), Vec3::Y); assert_eq!(Plane::YZ.normal(), Vec3::X); let p = Plane::XZ.to_world(Vec2::new(3.0, 4.0)); - assert_eq!(p, Vec3::new(3.0, 0.0, 4.0)); + assert_eq!(p, Vec3::new(3.0, 0.0, -4.0)); assert_eq!(Plane::XZ.to_local(p), Vec2::new(3.0, 4.0)); let hit = Plane::XY.offset(5.0).intersect_ray(Vec3::new(1.0, 2.0, 10.0), Vec3::new(0.0, 0.0, -2.0)).unwrap(); assert!(hit.dist(Vec3::new(1.0, 2.0, 5.0)) < 1e-12); diff --git a/crates/kernel/src/iges_in/tests.rs b/crates/kernel/src/iges_in/tests.rs index cf3e2594..cd9a832e 100644 --- a/crates/kernel/src/iges_in/tests.rs +++ b/crates/kernel/src/iges_in/tests.rs @@ -150,7 +150,8 @@ fn a_drill_point_round_trips_through_iges() { let plate = box_solid(Vec3::new(-20.0, -15.0, 0.0), Vec3::new(20.0, 15.0, 10.0)).unwrap(); let profile = Region2 { outer: Loop2::polygon(&[Vec2::new(0.0, 2.0), Vec2::new(4.0, 5.0), Vec2::new(4.0, 11.0), Vec2::new(0.0, 11.0)]), holes: vec![] }; - let hole = crate::revolve(&Plane::XZ, &[profile], Vec2::ZERO, Vec2::new(0.0, 1.0), std::f64::consts::TAU).unwrap().pop().unwrap(); + let plane = Plane { y: Vec3::Z, ..Plane::XZ }; // Positive profile heights are world +Z. + let hole = crate::revolve(&plane, &[profile], Vec2::ZERO, Vec2::new(0.0, 1.0), std::f64::consts::TAU).unwrap().pop().unwrap(); let drilled = boolean(&plate, &hole, BoolOp::Cut).unwrap().unwrap(); let v = 12000.0 - std::f64::consts::PI * 16.0 * (5.0 + 1.0); assert!(rel(measure(&drilled).unwrap().volume, v) < 1e-3); diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 44344d4d..f0e8e878 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -61,7 +61,7 @@ pub use lip::raise_band; pub use measure::{BodyMeasure, measure}; pub use meshbody::{MAX_TRIANGLES, mesh_body}; pub use offset::with_same_surface; -pub use ops::{BoolOp, boolean, split_by_plane, transform, transform_matrix}; +pub use ops::{BoolOp, boolean, can_join_touching_faces, split_by_plane, transform, transform_matrix}; pub use polybool::{faceted_boolean, planar_boolean}; pub use polyhedron::{HalfSpace, convex_polyhedron, draft, offset_faces, shell}; pub use sew::{EdgeSpec, FaceSpec, SurfSpec, sew}; diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs index ec3dc7a6..b812cbb7 100644 --- a/crates/kernel/src/ops.rs +++ b/crates/kernel/src/ops.rs @@ -15,6 +15,12 @@ pub enum BoolOp { Intersect, } +/// Whether a union can sew two matching planar faces without intersecting them. +/// Callers can retain the exact touching tool instead of extending it into material. +pub fn can_join_touching_faces(a: &Body, b: &Body) -> bool { + a.solid.boundaries().len() == 1 && b.solid.boundaries().len() == 1 && crate::coplanar::glue(a, b).is_some_and(|r| r.is_ok()) +} + /// Shifts applied to both operands on retries. The boolean classifies some faces by casting a /// ray whose direction is derived from point coordinates; moving both solids (and the result /// back) leaves the geometry unchanged but changes those rays. @@ -46,7 +52,10 @@ fn mismatch(a: &solvecraft_geom::Mesh, b: &solvecraft_geom::Mesh, result: &solve let hi = ba.max.min(bb.max); let all = ba.union(&bb); let mut boxes = vec![(all, 300usize)]; - if lo.x < hi.x && lo.y < hi.y && lo.z < hi.z { + // A touching seam can overlap by round-off only. Samples there sit on the + // operands' boundaries, where ray parity cannot classify membership reliably. + let gap = all.diagonal().max(1.0) * 1e-9; + if hi.x - lo.x > gap && hi.y - lo.y > gap && hi.z - lo.z > gap { boxes.push((solvecraft_geom::Aabb3 { min: lo, max: hi }, 900)); } let (ia_idx, ib_idx, ir_idx) = (a.inside_index(), b.inside_index(), result.inside_index()); diff --git a/crates/kernel/src/prism.rs b/crates/kernel/src/prism.rs index 1bf6c30a..b5a75d95 100644 --- a/crates/kernel/src/prism.rs +++ b/crates/kernel/src/prism.rs @@ -77,6 +77,7 @@ fn prism_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option { let mut hi = f64::NEG_INFINITY; let mut tops: Vec<&mt::Face> = Vec::new(); let mut top_h = Vec::new(); + let mut bottom_h = Vec::new(); for f in b.solid.face_iter() { let surf = f.oriented_surface(); if let mt::Surface::Plane(p) = &surf { @@ -90,6 +91,7 @@ fn prism_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option { continue; } if n.dot(d) < -1.0 + 1e-9 { + bottom_h.push(h); lo = lo.min(h); continue; } @@ -110,7 +112,7 @@ fn prism_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option { } } // One height for every top (and bottom) cap. - if top_h.iter().any(|h| (h - hi).abs() > tol) || !(lo.is_finite() && hi > lo + tol) { + if top_h.iter().any(|h| (h - hi).abs() > tol) || bottom_h.iter().any(|h| (h - lo).abs() > tol) || !(lo.is_finite() && hi > lo + tol) { return None; } let mut regions = Vec::new(); @@ -382,6 +384,129 @@ fn boolean2d(a: &[Region2], b: &[Region2], op: BoolOp, tol: f64) -> Option Option> { + if !matches!(seg, Seg2::Line { .. } | Seg2::Arc { .. }) { + return None; + } + let mut ts: Vec = pts + .iter() + .filter_map(|p| { + let t = seg.closest_param(*p); + (t > 1e-9 && t < 1.0 - 1e-9 && seg.point_at(t).dist(*p) < tol).then_some(t) + }) + .collect(); + ts.sort_by(f64::total_cmp); + ts.dedup_by(|x, y| (*x - *y).abs() < 1e-9); + let mut out = Vec::new(); + let mut t0 = 0.0; + for t1 in ts.into_iter().chain(std::iter::once(1.0)) { + out.push(match *seg { + Seg2::Line { .. } => Seg2::Line { a: seg.point_at(t0), b: seg.point_at(t1) }, + Seg2::Arc { center, radius, start, sweep } => Seg2::Arc { center, radius, start: start + sweep * t0, sweep: sweep * (t1 - t0) }, + _ => return None, + }); + t0 = t1; + } + Some(out) +} + +/// The union of two prisms along `d` stacked end to end (or overlapping) with different +/// sections, sewn from its faces: each slab between the prisms' end levels has walls over its +/// section's outline; each level has a face up over the section below less the one above, and +/// a face down over the one above less the one below. Every outline is split where any other +/// meets it, so the faces share their edges. `None` when a slab is empty or a curve isn't a +/// line or an arc. +fn sew_stack(plane: &Plane, d: Vec3, pa: &Prism, pb: &Prism, tol: f64) -> Option> { + use crate::sew::{EdgeSpec, FaceSpec, SurfSpec}; + let mut levels = vec![pa.lo, pa.hi, pb.lo, pb.hi]; + levels.sort_by(f64::total_cmp); + levels.dedup_by(|x, y| (*x - *y).abs() < tol); + let covers = |p: &Prism, z0: f64, z1: f64| p.lo <= z0 + tol && p.hi >= z1 - tol; + let mut slabs: Vec> = Vec::new(); + for w in levels.windows(2) { + let (z0, z1) = (*w.first()?, *w.get(1)?); + let s = match (covers(pa, z0, z1), covers(pb, z0, z1)) { + (true, true) => boolean2d(&pa.regions, &pb.regions, BoolOp::Union, tol)?, + (true, false) => pa.regions.clone(), + (false, true) => pb.regions.clone(), + (false, false) => return None, + }; + slabs.push(oriented(&s)); + } + // Faces at each level: up where the slab below sticks out, down where the one above does. + let diff = |x: &[Region2], y: &[Region2]| -> Option> { + match (x.is_empty(), y.is_empty()) { + (true, _) => Some(Vec::new()), + (false, true) => Some(x.to_vec()), + (false, false) => boolean2d(x, y, BoolOp::Cut, tol).map(|r| oriented(&r)), + } + }; + let mut caps: Vec<(f64, bool, Vec)> = Vec::new(); + for (k, z) in levels.iter().enumerate() { + let below: &[Region2] = if k == 0 { &[] } else { slabs.get(k - 1)? }; + let above: &[Region2] = slabs.get(k).map(Vec::as_slice).unwrap_or(&[]); + caps.push((*z, true, diff(below, above)?)); + caps.push((*z, false, diff(above, below)?)); + } + // Every segment end, everywhere: outlines split there. + let loops_of = + |rs: &[Region2]| -> Vec { rs.iter().flat_map(|r| std::iter::once(r.outer.clone()).chain(r.holes.iter().cloned())).collect() }; + let mut pts: Vec = Vec::new(); + for lp in slabs.iter().flat_map(|s| loops_of(s)).chain(caps.iter().flat_map(|c| loops_of(&c.2))) { + for sg in &lp.segs { + pts.push(sg.start()); + pts.push(sg.end()); + } + } + let at = |u: Vec2, z: f64| plane.to_world(u) + d * z; + let edge = |sg: &Seg2, z: f64| -> EdgeSpec { + match sg { + Seg2::Arc { .. } => EdgeSpec::Arc { a: at(sg.start(), z), b: at(sg.end(), z), mid: at(sg.point_at(0.5), z) }, + _ => EdgeSpec::Line { a: at(sg.start(), z), b: at(sg.end(), z) }, + } + }; + let mut faces: Vec = Vec::new(); + for (i, s) in slabs.iter().enumerate() { + let (z0, z1) = (*levels.get(i)?, *levels.get(i + 1)?); + for lp in loops_of(s) { + for sg0 in &lp.segs { + for sg in split_seg(sg0, &pts, tol)? { + let (a, b) = (sg.start(), sg.end()); + let lp3 = vec![ + edge(&sg, z0), + EdgeSpec::Line { a: at(b, z0), b: at(b, z1) }, + edge(&sg.reversed(), z1), + EdgeSpec::Line { a: at(a, z1), b: at(a, z0) }, + ]; + let tangent = plane.dir_to_world(sg.derivative(0.5)); + let outward = tangent.cross(d).normalized()?; + let surface = match sg { + Seg2::Arc { .. } => SurfSpec::Extruded { a: at(a, z0), b: at(b, z0), mid: at(sg.point_at(0.5), z0), dir: d * (z1 - z0) }, + _ => SurfSpec::Plane, + }; + faces.push(FaceSpec { loops: vec![lp3], surface, probe: Some((at(sg.point_at(0.5), (z0 + z1) / 2.0), outward)) }); + } + } + } + } + for (z, up, rs) in &caps { + for r in rs { + let mut loops = Vec::new(); + for lp in std::iter::once(&r.outer).chain(&r.holes) { + let mut segs: Vec = Vec::new(); + for sg in &lp.segs { + segs.extend(split_seg(sg, &pts, tol)?); + } + let segs: Vec = if *up { segs } else { segs.iter().rev().map(Seg2::reversed).collect() }; + loops.push(segs.iter().map(|sg| edge(sg, *z)).collect::>()); + } + faces.push(FaceSpec { loops, surface: SurfSpec::Plane, probe: None }); + } + } + Some(crate::sew::sew(&faces)) +} + pub(crate) fn prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option>> { let size = a.size().max(b.size()); let tol = (size * 1e-7).max(1e-9) * 10.0; @@ -423,10 +548,10 @@ pub(crate) fn prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option tol * (1.0 + u) || (u - y).abs() > tol * (1.0 + u) { - return None; + return sew_stack(&plane, d, &pa, &pb, tol).map(|r| r.map(Some)); } (pa.lo.min(pb.lo), pa.hi.max(pb.hi)) } else { diff --git a/crates/kernel/src/splitface.rs b/crates/kernel/src/splitface.rs index 6eeab3dd..c3432651 100644 --- a/crates/kernel/src/splitface.rs +++ b/crates/kernel/src/splitface.rs @@ -1208,7 +1208,7 @@ mod tests { let m0 = measure(&c).unwrap(); let side = c.faces(0.01).unwrap().iter().position(|f| f.plane_normal.is_none()).unwrap(); // A horizontal line seen from the front (XZ sketch) at z = 5, swept along y. - let xz = Plane::XZ; + let xz = Plane { y: Vec3::Z, ..Plane::XZ }; let tool = SplitTool::Curves { plane: xz, curves: vec![vec![Seg2::Line { a: Vec2::new(-20.0, 5.0), b: Vec2::new(20.0, 5.0) }]] }; let s = split_faces(&c, &[side], &tool).unwrap(); let m = measure(&s).unwrap(); diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs index 28e53b71..869a2639 100644 --- a/crates/kernel/src/tests.rs +++ b/crates/kernel/src/tests.rs @@ -40,8 +40,8 @@ fn extrude_on_other_planes_and_offsets() { let b = extrude(&Plane::XZ, &[rect(10.0, 5.0)], -2.0, 3.0).unwrap().pop().unwrap(); let m = measure(&b).unwrap(); assert!(rel(m.volume, 250.0) < 1e-9); - // XZ normal is -Y: the body spans y in [-3, 2]. - assert!((m.bbox.min.y + 3.0).abs() < 1e-6 && (m.bbox.max.y - 2.0).abs() < 1e-6, "{:?}", m.bbox); + // XZ normal is +Y: the body spans y in [-2, 3]. + assert!((m.bbox.min.y + 2.0).abs() < 1e-6 && (m.bbox.max.y - 3.0).abs() < 1e-6, "{:?}", m.bbox); } #[test] @@ -52,6 +52,39 @@ fn region_with_hole() { assert!(rel(m.volume, (1200.0 - PI * 25.0) * 10.0) < 5e-4, "{}", m.volume); } +#[test] +fn through_cut_preserves_a_larger_blind_pocket() { + let plate = box_solid(Vec3::new(-30.0, -28.0, 0.0), Vec3::new(60.0, 22.0, 8.0)).unwrap(); + let pocket = cylinder(Vec3::ZERO, Vec3::Z, 11.0, 6.0).unwrap(); + let through = cylinder(Vec3::new(0.0, 0.0, -1.0), Vec3::Z, 9.5, 10.0).unwrap(); + let expected = 90.0 * 50.0 * 8.0 - PI * 11.0_f64.powi(2) * 6.0 - PI * 9.5_f64.powi(2) * 2.0; + for reverse in [false, true] { + let (first, second) = if reverse { (&through, &pocket) } else { (&pocket, &through) }; + let cut = boolean(&plate, first, BoolOp::Cut).unwrap().unwrap(); + let result = boolean(&cut, second, BoolOp::Cut).unwrap().unwrap(); + let m = measure(&result).unwrap(); + assert!(rel(m.volume, expected) < 5e-4, "reverse={reverse}: {} vs {expected}", m.volume); + assert_eq!(m.merged.faces, 9); + assert!(!result.tessellate(0.01).unwrap().contains(Vec3::new(10.0, 0.0, 3.0))); + } +} + +#[test] +fn join_a_cylinder_on_its_chamfered_top() { + for seam in [0.0, crate::build::SEAM_ANGLE] { + let region = Region2 { outer: Loop2::circle_from(Vec2::ZERO, 10.0, seam), holes: vec![] }; + let base = extrude(&Plane::XY, &[region], 0.0, 20.0).unwrap().pop().unwrap(); + let base = chamfer(&base, &[Vec3::new(10.0, 0.0, 20.0)], 2.0).unwrap(); + let region = Region2 { outer: Loop2::circle_from(Vec2::new(0.0, -3.774758283725534e-15), 8.0, seam), holes: vec![] }; + let plane = Plane { origin: Vec3::new(0.0, 0.0, 20.000000000000007), ..Plane::XY }; + let tool = extrude(&plane, &[region], 0.0, 5.0).unwrap().pop().unwrap(); + let expected = measure(&base).unwrap().volume + PI * 64.0 * 5.0; + let joined = boolean(&base, &tool, BoolOp::Union).unwrap().unwrap(); + assert!(rel(measure(&joined).unwrap().volume, expected) < 5e-4); + assert_eq!(joined.lumps().unwrap().len(), 1); + } +} + #[test] fn box_fillet_cut_volume() { let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap(); @@ -1344,7 +1377,8 @@ fn boolean_identity_survey() { fn partial_revolve_on_the_axis() { let sq = Region2 { outer: Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(20.0, 0.0), Vec2::new(20.0, 7.5), Vec2::new(0.0, 7.5)]), holes: vec![] }; - let pie = revolve(&Plane::XZ, &[sq], Vec2::ZERO, Vec2::Y, std::f64::consts::FRAC_PI_2).unwrap().pop().unwrap(); + let plane = Plane { y: Vec3::Z, ..Plane::XZ }; // This fixture's rectangle is above z = 0. + let pie = revolve(&plane, &[sq], Vec2::ZERO, Vec2::Y, std::f64::consts::FRAC_PI_2).unwrap().pop().unwrap(); assert!(pie.validity().is_empty(), "{:?}", pie.validity()); let v = measure(&pie).unwrap().volume; assert!((v - PI * 400.0 / 4.0 * 7.5).abs() < 1e-3 * v, "{v}"); diff --git a/crates/sketch/src/lib.rs b/crates/sketch/src/lib.rs index 7e507456..81d8af22 100644 --- a/crates/sketch/src/lib.rs +++ b/crates/sketch/src/lib.rs @@ -13,6 +13,7 @@ mod dims; mod linalg; mod link; mod model; +mod offset; mod profiles; mod shape; mod solver; @@ -25,6 +26,7 @@ pub use curves::{ pub use dims::{DimFrame, DimLayout, chain_centres, default_text, dim_frame, dim_layout, encode_text}; pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES}; pub use model::{Constraint, ConstraintKind, Curve, CurveKind, SPoint, Sketch, SketchError, SketchView}; +pub use offset::OffsetChain; pub use profiles::{Profile, find_drawn_profiles, find_profiles, merge_regions}; pub use shape::{Shape, intersections}; pub use solver::{Hold, SolveReport, SolveStatus, solve, solve_holding}; diff --git a/crates/sketch/src/link.rs b/crates/sketch/src/link.rs index a7b3fe42..c02ce3da 100644 --- a/crates/sketch/src/link.rs +++ b/crates/sketch/src/link.rs @@ -104,6 +104,9 @@ pub struct Link { #[serde(default)] pub kind: LinkKind, pub source: LinkSource, + /// Persistent model face identity, resolved by the document before geometry refresh. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub face_name: Option, /// The reference could not be found on the last recompute; the geometry is the last good one. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub lost: bool, @@ -169,7 +172,7 @@ struct Layout { curves: Vec, } -#[derive(Clone, Copy, PartialEq)] +#[derive(Clone, Copy, Debug, PartialEq)] enum LCurve { Line(usize, usize), Circle(usize, f64), @@ -368,7 +371,7 @@ impl Sketch { return Err(SketchError::TooLarge); } let id = self.fresh("j"); - self.links.push(Link { id: id.clone(), kind, source, lost: false }); + self.links.push(Link { id: id.clone(), kind, source, face_name: None, lost: false }); self.build_link(&id, &lay)?; Ok(id) } @@ -579,6 +582,77 @@ impl Sketch { return Ok(false); } } + // A resized face can enumerate the same boundary in another order. Match the + // shared-point graph in normalized sketch space before discarding its identity. + if let Some(cur) = &cur + && points.len() == lay.pts.len() + && cur.len() == lay.curves.len() + && points.len() <= 512 + { + let old: Vec = points.iter().filter_map(|i| self.point(*i)).collect(); + let normalized = |ps: &[Vec2]| -> Vec { + let lo = ps.iter().fold(Vec2::new(f64::INFINITY, f64::INFINITY), |a, p| Vec2::new(a.x.min(p.x), a.y.min(p.y))); + let hi = ps.iter().fold(Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY), |a, p| Vec2::new(a.x.max(p.x), a.y.max(p.y))); + ps.iter().map(|p| Vec2::new((p.x - lo.x) / (hi.x - lo.x).max(1e-9), (p.y - lo.y) / (hi.y - lo.y).max(1e-9))).collect() + }; + let (a, b) = (normalized(&old), normalized(&lay.pts)); + let mut mapping = Vec::new(); + for p in &a { + let mut candidates: Vec<(usize, f64)> = b.iter().enumerate().map(|(i, q)| (i, p.dist(*q))).collect(); + candidates.sort_by(|x, y| x.1.total_cmp(&y.1)); + if let Some((i, d)) = candidates.first() + && *d < 0.1 + && candidates.get(1).is_none_or(|(_, second)| *d < *second * 0.5) + && !mapping.contains(i) + { + mapping.push(*i); + } else { + break; + } + } + let mut used = Vec::new(); + let mut curve_map = Vec::new(); + if mapping.len() == points.len() { + for c in cur { + let mapped: Option> = curve_points(c).iter().map(|i| mapping.get(*i).copied()).collect(); + let found = lay + .curves + .iter() + .enumerate() + .find(|(i, fresh)| { + if used.contains(i) || !same_shape(c, fresh) { + return false; + } + let fresh_pts = curve_points(fresh); + mapped.as_ref().is_some_and(|p| { + *p == fresh_pts || matches!(c, LCurve::Line(..)) && p.iter().rev().copied().collect::>() == fresh_pts + }) + }) + .map(|(i, _)| i); + if let Some(i) = found { + used.push(i); + curve_map.push(i); + } else { + break; + } + } + } + if curve_map.len() == cur.len() && mapping.len() == points.len() { + for (pi, li) in points.iter().zip(mapping) { + if let (Some(p), Some(q)) = (self.points.get_mut(*pi), lay.pts.get(li)) { + p.pos = *q; + } + } + for (ci, li) in curves.iter().zip(curve_map) { + if let (Some(Curve { kind: CurveKind::Circle { r, .. }, .. }), Some(LCurve::Circle(_, value))) = + (self.curves.get_mut(*ci), lay.curves.get(li)) + { + *r = *value; + } + } + return Ok(false); + } + } // Rebuild: drop the old entities (constraints on them go too) and make new ones. self.drop_link_entities(id); self.build_link(id, &lay)?; diff --git a/crates/sketch/src/model.rs b/crates/sketch/src/model.rs index 844bc999..d86535fb 100644 --- a/crates/sketch/src/model.rs +++ b/crates/sketch/src/model.rs @@ -467,6 +467,8 @@ pub struct Sketch { /// Links to geometry outside the sketch (projections, intersections, includes). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub links: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub offsets: Vec, /// 3D curves (world coordinates) carried by the sketch. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub wires: Vec, @@ -495,6 +497,7 @@ impl Sketch { curves: Vec::new(), constraints: Vec::new(), links: Vec::new(), + offsets: Vec::new(), wires: Vec::new(), counters: Default::default(), view: SketchView::default(), diff --git a/crates/sketch/src/offset.rs b/crates/sketch/src/offset.rs new file mode 100644 index 00000000..b3f06455 --- /dev/null +++ b/crates/sketch/src/offset.rs @@ -0,0 +1,116 @@ +//! Associative offsets keep their output entity ids while their source curves move. +use crate::{CurveKind, Shape, Sketch, SketchError, intersections}; +use serde::{Deserialize, Serialize}; +use solvecraft_geom::Vec2; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct OffsetChain { + pub sources: Vec<(String, bool)>, + pub outputs: Vec, + pub distance: f64, + pub closed: bool, +} + +impl Sketch { + pub fn refresh_offsets(&mut self) -> Result<(), SketchError> { + for offset in self.offsets.clone() { + if offset.sources.len() != offset.outputs.len() || offset.sources.len() > 10_000 || !offset.distance.is_finite() { + return Err(SketchError::Invalid("damaged offset chain".into())); + } + let bad = || SketchError::Invalid("an offset source vanished or changed shape".into()); + let mut pieces = Vec::new(); + for (id, forward) in &offset.sources { + let i = self.curve_index(id).ok_or_else(bad)?; + let kind = &self.curves.get(i).ok_or_else(bad)?.kind; + let piece = match *kind { + CurveKind::Line { a, b } => { + let (a, b) = (self.point(a).ok_or_else(bad)?, self.point(b).ok_or_else(bad)?); + let (a, b) = if *forward { (a, b) } else { (b, a) }; + let n = (b - a).normalized().ok_or_else(bad)?.perp() * offset.distance; + (Shape::Line { a: a + n, b: b + n }, a + n, b + n) + } + CurveKind::Arc { c, a, b } => { + let (c, a, b) = (self.point(c).ok_or_else(bad)?, self.point(a).ok_or_else(bad)?, self.point(b).ok_or_else(bad)?); + let r = c.dist(a) - if *forward { offset.distance } else { -offset.distance }; + if r <= 1e-9 { + return Err(SketchError::Invalid("the offset collapses an arc".into())); + } + let a = c + (a - c).normalized().ok_or_else(bad)? * r; + let b = c + (b - c).normalized().ok_or_else(bad)? * r; + let (a, b) = if *forward { (a, b) } else { (b, a) }; + (Shape::Round { c, r, start: 0.0, sweep: std::f64::consts::TAU }, a, b) + } + CurveKind::Circle { c, r } => { + let c = self.point(c).ok_or_else(bad)?; + let r = r - offset.distance; + if r <= 1e-9 { + return Err(SketchError::Invalid("the offset collapses a circle".into())); + } + let out = offset.outputs.first().and_then(|id| self.curve_index(id)).ok_or_else(bad)?; + if let Some(crate::Curve { kind: CurveKind::Circle { r: target, .. }, .. }) = self.curves.get_mut(out) { + *target = r; + } else { + return Err(bad()); + } + let _ = c; + continue; + } + _ => return Err(bad()), + }; + pieces.push(piece); + } + if pieces.is_empty() { + continue; + } + let joint = |i: usize, j: usize| -> Result { + let (x, y) = (pieces.get(i).ok_or_else(bad)?, pieces.get(j).ok_or_else(bad)?); + if x.2.dist(y.1) < 1e-6 { + return Ok(x.2); + } + let guess = (x.2 + y.1) * 0.5; + intersections(&x.0, &y.0) + .into_iter() + .min_by(|a, b| a.dist(guess).total_cmp(&b.dist(guess))) + .ok_or_else(|| SketchError::Invalid("the offset curves no longer meet".into())) + }; + let n = pieces.len(); + let mut joints = vec![if offset.closed { joint(n - 1, 0)? } else { pieces.first().ok_or_else(bad)?.1 }]; + for i in 0..n.saturating_sub(1) { + joints.push(joint(i, i + 1)?); + } + if !offset.closed { + joints.push(pieces.last().ok_or_else(bad)?.2); + } + for (i, id) in offset.outputs.iter().enumerate() { + let a = *joints.get(i).ok_or_else(bad)?; + let b = *joints.get(if offset.closed { (i + 1) % n } else { i + 1 }).ok_or_else(bad)?; + let shape = &pieces.get(i).ok_or_else(bad)?.0; + if let Shape::Line { a: p, b: q } = shape + && (b - a).dot(*q - *p) <= 1e-9 + { + return Err(SketchError::Invalid("the offset curves would cross".into())); + } + let ci = self.curve_index(id).ok_or_else(bad)?; + let kind = self.curves.get(ci).ok_or_else(bad)?.kind.clone(); + let (pa, pb) = match kind { + CurveKind::Line { a, b } => (a, b), + CurveKind::Arc { a, b, .. } => { + if offset.sources.get(i).is_some_and(|x| x.1) { + (a, b) + } else { + (b, a) + } + } + _ => return Err(bad()), + }; + for (pi, pos) in [(pa, a), (pb, b)] { + if let Some(p) = self.points.get_mut(pi) { + p.pos = pos; + p.fixed = true; + } + } + } + } + Ok(()) + } +} diff --git a/crates/sketch/src/wire.rs b/crates/sketch/src/wire.rs index 62ff4fe7..ab0fb296 100644 --- a/crates/sketch/src/wire.rs +++ b/crates/sketch/src/wire.rs @@ -98,7 +98,7 @@ impl Sketch { } let id = if geom.is_empty() { let id = self.fresh("j"); - self.links.push(Link { id: id.clone(), kind, source, lost: false }); + self.links.push(Link { id: id.clone(), kind, source, face_name: None, lost: false }); id } else { self.add_link(kind, source, geom)? diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs index f073b10a..5fbc0c8f 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -232,41 +232,41 @@ const INDENT: f32 = 14.0; /// One row's look. #[derive(Default)] -struct Row<'a> { - depth: usize, +pub(crate) struct Row<'a> { + pub(crate) depth: usize, /// Some(open) for rows that fold. - fold: Option, + pub(crate) fold: Option, /// Some(visible) for rows with an eye. - eye: Option, - icon: &'a str, - label: &'a str, - selected: bool, - dim: bool, - color: Option, + pub(crate) eye: Option, + pub(crate) icon: &'a str, + pub(crate) label: &'a str, + pub(crate) selected: bool, + pub(crate) dim: bool, + pub(crate) color: Option, /// Component rows: is it the active one? - radio: Option, + pub(crate) radio: Option, /// Trailing badges: "lock", "pin". - badges: &'a [&'a str], + pub(crate) badges: &'a [&'a str], } /// What happened on a row. #[derive(Default)] -struct RowResp { - rect: Option, - clicked: bool, - double: bool, - secondary: Option, - fold: bool, - eye: bool, - radio: bool, - hovered: bool, +pub(crate) struct RowResp { + pub(crate) rect: Option, + pub(crate) clicked: bool, + pub(crate) double: bool, + pub(crate) secondary: Option, + pub(crate) fold: bool, + pub(crate) eye: bool, + pub(crate) radio: bool, + pub(crate) hovered: bool, /// The row's response (drag and drop). - resp: Option, + pub(crate) resp: Option, /// The press began on the fold arrow, eye or radio (not a drag of the row). - on_control: bool, + pub(crate) on_control: bool, } -fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp { +pub(crate) fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp { let t = Tokens::get(); // Rows scrolled out of view only take their space: no widgets, no painting. let r = Rect::from_min_size(ui.cursor().min, vec2(ui.available_width(), ROW_H)); @@ -449,11 +449,11 @@ fn sel_of_key(k: &str) -> Option { None } -fn is_open(app: &SolveApp, key: &str, default: bool) -> bool { +pub(crate) fn is_open(app: &SolveApp, key: &str, default: bool) -> bool { if default { !app.tree.collapsed.contains(key) } else { app.tree.expanded.contains(key) } } -fn toggle(app: &mut SolveApp, key: &str, default: bool) { +pub(crate) fn toggle(app: &mut SolveApp, key: &str, default: bool) { if default { if !app.tree.collapsed.remove(key) { app.tree.collapsed.insert(key.to_string()); diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index 6381df63..3b9ba9ab 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -493,7 +493,10 @@ fn joint_items(app: &SolveApp, id: u64) -> Vec { let rigid = j.kind == solvecraft_engine::doc::joints::JointKind::Rigid; vec![ act("ui.editJoint", "Edit Joint", "joint").with(json!({ "joint": id })), + act("ui.editJoint", "Edit Joint Limits", "").with(json!({ "joint": id })).on(!rigid), act("ui.driveJoint", "Drive Joint", "").with(json!({ "joint": id })).on(!rigid), + act("ui.animateJoint", "Animate Joint", "").with(json!({ "joint": id })).on(!rigid && !j.suppressed), + act("ui.findJoint", "Find in Window", "").with(json!({ "joint": id })), cmd(app, "joint.edit", if j.suppressed { "Unsuppress" } else { "Suppress" }).with(json!({ "joint": id, "suppressed": !j.suppressed })), Item::sep(), cmd(app, "joint.delete", "Delete").key("Del").with(json!({ "joint": id })), @@ -792,12 +795,17 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) { "ui.canvasVisible" => drop(app.run("canvas.edit", p)), "ui.toolbarPin" | "ui.toolbarRemove" | "ui.toolbarReset" | "ui.shortcutPin" => crate::toolbar_custom::run(app, &item.id, &p), "ui.workspace" => {} - "ui.editJoint" | "ui.driveJoint" => { + "ui.findJoint" => { if let Some(id) = id_of(&p, "joint") { - let d = if item.id == "ui.editJoint" { - crate::dialogs_assembly::edit_joint(app, id) - } else { - crate::dialogs_assembly::drive_joint(app, id) + crate::dialogs_assembly::find_joint(app, id); + } + } + "ui.editJoint" | "ui.driveJoint" | "ui.animateJoint" => { + if let Some(id) = id_of(&p, "joint") { + let d = match item.id.as_str() { + "ui.editJoint" => crate::dialogs_assembly::edit_joint(app, id), + "ui.animateJoint" => crate::dialogs_assembly::animate_joint(app, id), + _ => crate::dialogs_assembly::drive_joint(app, id), }; if let Some(d) = d { app.tool = None; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index f56162aa..d1382537 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2691,11 +2691,50 @@ pub(crate) fn profile_indices(ss: &solvecraft_engine::doc::SolvedSketch, sel: &P } } +thread_local! { + /// A dialog is being filled from a feature: its points are in component frames. + static REFILL: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// While filling a dialog from a feature (dropped at the end). +struct Refill; + +impl Refill { + fn start() -> Refill { + REFILL.with(|r| r.set(true)); + Refill + } +} + +impl Drop for Refill { + fn drop(&mut self) { + REFILL.with(|r| r.set(false)); + } +} + +/// A point stored in a feature, as it shows on world body `body`: features keep their picks in +/// the frame of the body they were on, so each body's placement takes the point to the world +/// (a moved component, a pasted instance). Other points are the world already. +fn stored_on(frames: &Option>, body: &str, p: Vec3) -> Vec3 { + frames.as_ref().and_then(|f| f.iter().find(|(n, _)| n == body)).map(|(_, m)| solvecraft_engine::doc::apply_point(m, p)).unwrap_or(p) +} + +/// Body placements while filling a dialog from a feature (when anything is placed). +fn stored_frames(s: &Session) -> Option> { + if !REFILL.with(std::cell::Cell::get) || !solvecraft_engine::frames::placed(s) { + return None; + } + let st = s.model.state(); + Some(s.doc.body_frames(&st).into_iter().map(|(w, _, _, m)| (w, m)).collect()) +} + /// The edge of a visible body through (or nearest) a point. -pub(crate) fn edge_sel(s: &Session, p: Vec3) -> Option { +pub(crate) fn edge_sel(s: &Session, p0: Vec3) -> Option { let st = s.world_state(); + let frames = stored_frames(s); let mut best: Option<(f64, Sel)> = None; for b in &st.bodies { + let p = stored_on(&frames, &b.name, p0); let m = b.mesh(); for (i, e) in m.edges.iter().enumerate() { let d = e.windows(2).map(|w| p.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min); @@ -2708,10 +2747,12 @@ pub(crate) fn edge_sel(s: &Session, p: Vec3) -> Option { } /// The body face containing a point (nearest triangle). -pub(crate) fn face_sel(s: &Session, p: Vec3) -> Option { +pub(crate) fn face_sel(s: &Session, p0: Vec3) -> Option { let st = s.world_state(); + let frames = stored_frames(s); let mut best: Option<(f64, Sel)> = None; for b in &st.bodies { + let p = stored_on(&frames, &b.name, p0); let m = b.mesh(); for (t, f) in m.triangles.iter().zip(&m.tri_face) { let Some([a, bb, c]) = m.tri(t) else { continue }; @@ -2721,7 +2762,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) + best.filter(|(d, _)| *d < 1e-3 + 1e-6 * p0.len()).map(|x| x.1) } pub(crate) fn plane_sel(s: &Session, pl: &PlaneRef) -> Option { @@ -2756,6 +2797,7 @@ fn pt3(v: Vec3) -> Value { /// A dialog that edits an existing feature, filled from it. The timeline is rolled back to just /// before the feature, so its references show on the geometry they refer to. pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option { + let _refill = Refill::start(); let s = &app.session; let f = s.doc.feature(id)?.clone(); let st = s.world_state(); diff --git a/crates/ui-egui/src/dialogs_assembly.rs b/crates/ui-egui/src/dialogs_assembly.rs index ed679646..07d08727 100644 --- a/crates/ui-egui/src/dialogs_assembly.rs +++ b/crates/ui-egui/src/dialogs_assembly.rs @@ -209,6 +209,38 @@ pub fn edit_joint(app: &SolveApp, id: u64) -> Option { } /// The Drive Joints dialog on a joint (by id). +/// Edit Joint with its motion playing (the browser's Animate Joint). +pub fn animate_joint(app: &SolveApp, id: u64) -> Option { + let mut d = edit_joint(app, id)?; + if let Kind::Assembly(Asm::Joint(f)) = &mut d.kind { + f.animate = true; + } + Some(d) +} + +/// Frame the two components a joint connects (the browser's Find in Window). +pub fn find_joint(app: &mut SolveApp, id: u64) { + let s = &app.session; + let Some(j) = s.doc.assembly.joints.iter().find(|j| j.id == id) else { return }; + let occs = [j.a.occurrence, j.b.occurrence]; + let model = s.model.state(); + let world = s.world_state(); + let mut bb = Aabb3::default(); + for (name, _, path, _) in s.doc.body_frames(&model) { + // The root (occurrence 0) holds the bodies outside every occurrence. + let on = occs.iter().any(|o| if *o == 0 { path.is_empty() } else { path.contains(o) }); + if on && let Some(b) = world.body(&name) { + bb = bb.union(&b.mesh().bounds()); + } + } + if bb.is_empty() { + return; + } + let mut cam = app.cam; + cam.fit(&bb); + app.animate_to(cam); +} + pub fn drive_joint(app: &SolveApp, id: u64) -> Option { let joint = app.session.doc.assembly.joints.iter().position(|j| j.id == id)?; Some(Dialog::new(Kind::Assembly(Asm::Drive { joint, values: current_values(&app.session, joint), focus: true }), vec![])) @@ -494,12 +526,25 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Asm, inputs: &mut [Se let cur = s.doc.eval(v, unit_kind).ok().filter(|x| x.is_finite()).unwrap_or(0.0); let (mut shown, scale) = if angle { (cur.to_degrees(), 1f64.to_degrees()) } else { (cur, 1.0) }; row_label(ui, ""); - ui.spacing_mut().slider_width = 124.0; + ui.spacing_mut().slider_width = 160.0; let r = ui.add(egui::Slider::new(&mut shown, lo * scale..=hi * scale).show_value(false)); if r.changed() { *v = if angle { format!("{shown:.1} deg") } else { format!("{shown:.2} mm") }; } ui.end_row(); + // The slider's range: the joint's limits, or the default span when it has none. + let limited = j.limits.get(i).copied().flatten().is_some(); + let fmt = |x: f64| { + let (x, unit) = if angle { (x.to_degrees(), "°") } else { (x, " mm") }; + if x.fract().abs() < 1e-6 { format!("{x:.0}{unit}") } else { format!("{x:.1}{unit}") } + }; + row_label(ui, ""); + ui.label( + RichText::new(format!("{} … {}{}", fmt(lo), fmt(hi), if limited { " (limits)" } else { " (no limits)" })) + .size(11.0) + .color(if limited { t.text } else { t.text_dim }), + ); + ui.end_row(); } } Asm::MotionLink { a, b, ia, ib, ratio, offset } => { @@ -826,6 +871,8 @@ pub fn overlay(app: &SolveApp, painter: &egui::Painter, proj: &Proj, d: &mut Dia } return; } + // A joint names its two picks; a joint origin has one. + let two = matches!(k, Asm::Joint(_)); let hover = match k { Asm::Joint(f) => Some(&mut f.hover), Asm::JointOrigin { hover, .. } => Some(hover), @@ -840,8 +887,9 @@ pub fn overlay(app: &SolveApp, painter: &egui::Painter, proj: &Proj, d: &mut Dia .filter(|(_, i)| i.accept == SNAPS) .filter_map(|(n, i)| i.items.first().and_then(|x| snap_of(&app.session, x)).map(|s| (s, if n == 0 { t.accent } else { t.warning }))) .collect(); - for (s, c) in &picked { - glyph(painter, proj, s, *c, app.cam.half_height()); + for (n, (s, c)) in picked.iter().enumerate() { + let tag = format!("Component {}", n + 1); + glyph(painter, proj, s, *c, app.cam.half_height(), two.then_some(tag.as_str())); } // The snap under the cursor (found again only when the hovered item changes). let accepting = d.inputs.get(d.active).is_some_and(|i| i.accept == SNAPS); @@ -853,7 +901,7 @@ pub fn overlay(app: &SolveApp, painter: &egui::Painter, proj: &Proj, d: &mut Dia *hover = Some((sel.clone(), snap_of(&app.session, &sel))); } if let Some((_, Some(s))) = hover.as_ref() { - glyph(painter, proj, s, t.hover_profile_edge, app.cam.half_height()); + glyph(painter, proj, s, t.hover_profile_edge, app.cam.half_height(), Some(snap_name(s.kind))); } } None => *hover = None, @@ -861,36 +909,70 @@ pub fn overlay(app: &SolveApp, painter: &egui::Painter, proj: &Proj, d: &mut Dia } /// A snap: its kind's marker at the point and the z axis as an arrow. -fn glyph(painter: &egui::Painter, proj: &Proj, s: &SnapPt, col: Color32, half_height: f64) { +fn glyph(painter: &egui::Painter, proj: &Proj, s: &SnapPt, col: Color32, half_height: f64, tag: Option<&str>) { let Some(c) = proj.to_screen(s.at) else { return }; let len = half_height * 2.0 * 0.06; + // A dark halo under every stroke keeps the glyph readable on light and dark faces alike. + let halo = Stroke::new(4.5, Color32::from_black_alpha(150)); + let white = Stroke::new(1.5, Color32::WHITE); if let Some(tip) = proj.to_screen(s.at + s.z * len) { - painter.line_segment([c, tip], Stroke::new(2.0, col)); let d = (tip - c).normalized(); let n = vec2(-d.y, d.x); - painter.add(egui::Shape::convex_polygon(vec![tip, tip - d * 8.0 + n * 4.0, tip - d * 8.0 - n * 4.0], col, Stroke::NONE)); + let head = vec![tip + d * 2.0, tip - d * 9.0 + n * 5.0, tip - d * 9.0 - n * 5.0]; + painter.line_segment([c, tip], halo); + painter.add(egui::Shape::convex_polygon(head.clone(), Color32::TRANSPARENT, halo)); + painter.line_segment([c, tip], Stroke::new(2.0, col)); + painter.add(egui::Shape::convex_polygon(head, col, Stroke::NONE)); } - let white = Stroke::new(1.5, Color32::WHITE); + let shape = |fill: Color32, stroke: Stroke, grow: f32| -> egui::Shape { + match s.kind { + SnapKind::FaceCenter => egui::Shape::rect_filled(egui::Rect::from_center_size(c, vec2(12.0 + grow, 12.0 + grow)), 1.5, fill), + SnapKind::CircleCenter => egui::Shape::circle_filled(c, 7.0 + grow / 2.0, fill), + SnapKind::EdgeMid => { + let r = 8.0 + grow / 2.0; + egui::Shape::convex_polygon(vec![c + vec2(0.0, -r), c + vec2(r * 0.93, r * 0.7), c + vec2(-r * 0.93, r * 0.7)], fill, stroke) + } + SnapKind::Vertex => { + let r = 7.5 + grow / 2.0; + egui::Shape::convex_polygon(vec![c + vec2(0.0, -r), c + vec2(r, 0.0), c + vec2(0.0, r), c + vec2(-r, 0.0)], fill, stroke) + } + } + }; + painter.add(shape(Color32::from_black_alpha(150), Stroke::NONE, 4.0)); + painter.add(shape(col, Stroke::NONE, 0.0)); match s.kind { SnapKind::FaceCenter => { - painter.rect(egui::Rect::from_center_size(c, vec2(10.0, 10.0)), 1.0, col, white, egui::StrokeKind::Middle); + painter.rect_stroke(egui::Rect::from_center_size(c, vec2(12.0, 12.0)), 1.5, white, egui::StrokeKind::Middle); } SnapKind::CircleCenter => { - painter.circle(c, 6.0, col, white); - painter.line_segment([c - vec2(4.0, 0.0), c + vec2(4.0, 0.0)], Stroke::new(1.2, Color32::WHITE)); - painter.line_segment([c - vec2(0.0, 4.0), c + vec2(0.0, 4.0)], Stroke::new(1.2, Color32::WHITE)); + painter.circle_stroke(c, 7.0, white); + painter.line_segment([c - vec2(4.5, 0.0), c + vec2(4.5, 0.0)], Stroke::new(1.2, Color32::WHITE)); + painter.line_segment([c - vec2(0.0, 4.5), c + vec2(0.0, 4.5)], Stroke::new(1.2, Color32::WHITE)); } - SnapKind::EdgeMid => { - painter.add(egui::Shape::convex_polygon(vec![c + vec2(0.0, -7.0), c + vec2(6.5, 5.0), c + vec2(-6.5, 5.0)], col, white)); - } - SnapKind::Vertex => { - painter.add(egui::Shape::convex_polygon( - vec![c + vec2(0.0, -6.5), c + vec2(6.5, 0.0), c + vec2(0.0, 6.5), c + vec2(-6.5, 0.0)], - col, - white, - )); + SnapKind::EdgeMid | SnapKind::Vertex => { + painter.add(shape(Color32::TRANSPARENT, white, 0.0)); } } + // What it snaps to (hovered), or which component it is (picked): a small label. + if let Some(tag) = tag { + let t = Tokens::get(); + let font = egui::FontId::proportional(11.5); + let g = painter.layout_no_wrap(tag.to_string(), font, t.text); + let at = c + vec2(12.0, 10.0); + let r = egui::Rect::from_min_size(at, g.size()).expand2(vec2(5.0, 2.0)); + painter.rect(r, 3.0, t.panel.gamma_multiply(0.92), Stroke::new(1.0, col), egui::StrokeKind::Inside); + painter.galley(at, g, t.text); + } +} + +/// What a snap is called (the hover label). +fn snap_name(k: SnapKind) -> &'static str { + match k { + SnapKind::FaceCenter => "Face center", + SnapKind::CircleCenter => "Circle center", + SnapKind::EdgeMid => "Edge midpoint", + SnapKind::Vertex => "Vertex", + } } // ---- timeline and browser ---- @@ -963,61 +1045,36 @@ pub fn timeline_joints(app: &mut SolveApp, ui: &mut egui::Ui, p: &egui::Painter, /// The Joints folder at the end of the Browser: a row per joint (double-click edits, the menu /// edits, drives, suppresses or deletes). pub fn browser_joints(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize) { - let t = Tokens::get(); + use crate::browser::{Row, draw_row, is_open, toggle}; let joints: Vec<(u64, String, JointKind, bool)> = app.session.doc.assembly.joints.iter().map(|j| (j.id, j.name.clone(), j.kind, j.suppressed)).collect(); if joints.is_empty() { return; } - let fold_id = egui::Id::new("sc_browser_joints_open"); - let mut open = ui.data(|d| d.get_temp::(fold_id)).unwrap_or(true); - let row = |ui: &mut egui::Ui, depth: f32, icon: &str, label: &str, dim: bool, fold: Option| -> egui::Response { - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), egui::Sense::click()); - if resp.hovered() { - ui.painter().rect_filled(r, 3.0, t.hover); - } - let x = r.left() + 2.0 + depth * 14.0; - let cy = r.center().y; - if let Some(o) = fold { - let c = egui::pos2(x + 6.0, cy); - let pts = if o { - vec![c + vec2(-4.0, -2.0), c + vec2(4.0, -2.0), c + vec2(0.0, 3.0)] - } else { - vec![c + vec2(-2.0, -4.0), c + vec2(3.0, 0.0), c + vec2(-2.0, 4.0)] - }; - ui.painter().add(egui::Shape::convex_polygon(pts, t.text_dim, Stroke::NONE)); - } - let ir = egui::Rect::from_center_size(egui::pos2(x + 26.0, cy), vec2(16.0, 16.0)); - crate::icons::paint(ui.painter(), ir, icon, if dim { t.border } else { t.icon }, t.icon_fill, t.accent); - ui.painter().text( - egui::pos2(x + 38.0, cy), - egui::Align2::LEFT_CENTER, - label, - egui::FontId::proportional(13.0), - if dim { t.text_dim } else { t.text }, - ); - resp - }; - let depth = depth as f32; - if row(ui, depth, "folder", "Joints", false, Some(open)).clicked() { - open = !open; - ui.data_mut(|d| d.insert_temp(fold_id, open)); + // Drawn like every other browser node (its fold and rows publish `fold:Joints`, `row:`). + let key = "c0/joints"; + let open = is_open(app, key, true); + let r = draw_row(ui, ui.id().with(key), &Row { depth, fold: Some(open), icon: "folder", label: "Joints", ..Default::default() }); + if r.fold || r.clicked { + toggle(app, key, true); } if !open { return; } let mut dialog: Option = None; for (id, name, kind, suppressed) in joints { - let resp = row(ui, depth + 1.0, "joint", &name, suppressed, None).on_hover_text(format!("{kind:?} joint")); - if resp.double_clicked() { + let r = + draw_row(ui, ui.id().with(("joint", id)), &Row { depth: depth + 1, icon: "joint", label: &name, dim: suppressed, ..Default::default() }); + if let Some(resp) = &r.resp { + resp.clone().on_hover_text(format!("{kind:?} joint")); + } + if r.double { dialog = edit_joint(app, id); } // The same menus as the rest of the browser. - if resp.secondary_clicked() { - let at = resp.interact_pointer_pos().unwrap_or(resp.rect.left_bottom()); + if let Some(at) = r.secondary { crate::context_menu::open_for(app, at, crate::context_menu::Target::Joint { id }); } - let _ = (kind, suppressed); } if let Some(d) = dialog { app.tool = None; @@ -1028,3 +1085,51 @@ pub fn browser_joints(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize) { #[cfg(test)] #[path = "dialogs_assembly_tests.rs"] mod tests; + +thread_local! { + /// Named joint origins in the world, for the design state they were found in. + static ORIGINS: std::cell::RefCell)>> = const { std::cell::RefCell::new(None) }; +} + +/// Named joint origins in the world: on their occurrence's component, so they move with it +/// (joint drives, free moves not captured yet, Capture Position). +pub fn origins_world(s: &Session) -> Vec<(String, solvecraft_engine::doc::Mat)> { + if s.doc.assembly.origins.is_empty() { + return Vec::new(); + } + let key = format!("{}:{:?}", s.revision, s.pending_moves); + if let Some(v) = ORIGINS.with(|o| o.borrow().as_ref().filter(|(k, _)| *k == key).map(|(_, v)| v.clone())) { + return v; + } + let doc: std::borrow::Cow = if s.pending_moves.is_empty() { + std::borrow::Cow::Borrowed(&*s.doc) + } else { + let mut d = (*s.doc).clone(); + for o in &mut d.occurrences { + if let Some(m) = s.pending_moves.get(&o.id) { + o.transform = *m; + } + } + std::borrow::Cow::Owned(d) + }; + let st = s.model.state(); + let v: Vec<_> = doc.assembly.origins.iter().filter_map(|o| doc.origin_world(&st, &o.origin).ok().map(|m| (o.name.clone(), m))).collect(); + ORIGINS.with(|o| *o.borrow_mut() = Some((key, v.clone()))); + v +} + +/// Named joint origins in the viewport: a small triad where each sits. +pub fn origins(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { + let t = Tokens::get(); + let len = app.cam.half_height() * 2.0 * 0.04; + for (_, m) in origins_world(&app.session) { + let at = solvecraft_engine::doc::apply_point(&m, Vec3::ZERO); + let Some(c) = proj.to_screen(at) else { continue }; + for (axis, col) in [(Vec3::X, t.axis_x), (Vec3::Y, t.axis_y), (Vec3::Z, t.axis_z)] { + if let Some(tip) = proj.to_screen(at + solvecraft_engine::doc::apply_vector(&m, axis) * len) { + painter.line_segment([c, tip], Stroke::new(2.0, col)); + } + } + painter.circle(c, 4.5, t.panel, Stroke::new(1.5, t.text)); + } +} diff --git a/crates/ui-egui/src/dialogs_assembly_tests.rs b/crates/ui-egui/src/dialogs_assembly_tests.rs index 083645fe..ade77ceb 100644 --- a/crates/ui-egui/src/dialogs_assembly_tests.rs +++ b/crates/ui-egui/src/dialogs_assembly_tests.rs @@ -263,3 +263,41 @@ fn drive_value_takes_the_keyboard() { assert!((j.values[0] - 90f64.to_radians()).abs() < 1e-9, "{:?}", j.values); assert!(app.dialog.is_none()); } + +/// Named joint origins are drawn where their component is: a drive of the occurrence, or a free +/// move not captured yet, carries them along. +#[test] +fn joint_origins_follow_their_component() { + let mut app = two_boxes(); + app.session.execute("joint.origin", &json!({"name": "Top of B", "face": [40, 10, 10]})).unwrap(); + let at = |app: &SolveApp| { + let v = origins_world(&app.session); + let (_, m) = v.iter().find(|(n, _)| n == "Top of B").unwrap(); + solvecraft_engine::doc::apply_point(m, Vec3::ZERO) + }; + assert!(at(&app).dist(Vec3::new(40.0, 10.0, 10.0)) < 1e-6, "{:?}", at(&app)); + let b = occ(&app, "B"); + app.session.execute("occurrence.move", &json!({"occurrence": b, "translate": [0, 0, 15]})).unwrap(); + assert!(at(&app).dist(Vec3::new(40.0, 10.0, 25.0)) < 1e-6, "{:?}", at(&app)); + // A drag not captured yet. + let mut m = app.session.doc.occurrence_of(app.session.doc.find_component("B").unwrap()).unwrap().transform; + m[3][0] += 5.0; + app.session.pending_moves.insert(b, m); + assert!(at(&app).dist(Vec3::new(45.0, 10.0, 25.0)) < 1e-6, "{:?}", at(&app)); +} + +/// Edit Feature on a moved component: the dialog shows the feature's picks where the +/// component is (its stored points are in the component's frame). +#[test] +fn edit_feature_on_a_moved_component_refills_in_the_world() { + let mut app = two_boxes(); + let b = occ(&app, "B"); + app.session.execute("occurrence.move", &json!({"occurrence": b, "translate": [0, 0, 15]})).unwrap(); + let r = app.session.execute("solid.fillet", &json!({"edges": [[40, 0, 25]], "radius": 2})).unwrap(); + let id = r["feature"].as_u64().unwrap(); + let d = crate::dialogs::for_feature(&app, id, None).unwrap(); + let items: Vec = d.inputs.iter().flat_map(|i| i.items.clone()).collect(); + let Some(Sel::Edge { body, point, .. }) = items.first() else { panic!("{items:?}") }; + assert_eq!(body, "Body2"); + assert!(point.dist(Vec3::new(40.0, 0.0, 25.0)) < 1e-6, "{point:?}"); +} diff --git a/crates/ui-egui/src/dialogs_motion.rs b/crates/ui-egui/src/dialogs_motion.rs index 02d186f0..5331e429 100644 --- a/crates/ui-egui/src/dialogs_motion.rs +++ b/crates/ui-egui/src/dialogs_motion.rs @@ -41,6 +41,10 @@ pub enum Mo { tracks: Vec, step: f64, playing: bool, + /// Playback starts over at the end (else it stops there). + looping: bool, + /// Playback speed (index into `SPEEDS`). + speed: usize, /// The step the shown pose is for (and its revision). shown: Option<(u64, u64)>, saved: Option>, @@ -101,6 +105,8 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { tracks: Vec::new(), step: 0.0, playing: false, + looping: true, + speed: 2, shown: None, saved: None, error: None, @@ -116,6 +122,73 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { // ---- pure mappings (tested) ---- +/// Playback speeds offered (times `STEPS_PER_S`). +pub const SPEEDS: [f64; 5] = [0.25, 0.5, 1.0, 2.0, 4.0]; +/// Steps played per second at 1×. +const STEPS_PER_S: f64 = 24.0; + +/// The next playback step: `by` steps on; at the end it starts over (looping) or stops there. +/// Returns the step and whether it still plays. +pub fn advance(step: f64, by: f64, total: u32, looping: bool) -> (f64, bool) { + let last = f64::from(total); + let next = step + by.max(0.0); + if next <= last { + (next, true) + } else if looping && last > 0.0 { + (next % (last + 1.0), true) + } else { + (last, false) + } +} + +#[derive(Clone, Copy)] +enum Transport { + Start, + Back, + Play, + Stop, + Forward, + End, +} + +/// A media button drawn in code: bars and triangles. +fn transport(ui: &mut egui::Ui, what: Transport, tip: &str) -> egui::Response { + let t = Tokens::get(); + let (r, resp) = ui.allocate_exact_size(vec2(26.0, 22.0), Sense::click()); + let fill = if resp.hovered() { t.hover } else { t.field }; + ui.painter().rect(r, 3.0, fill, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + let c = r.center(); + let ink = if matches!(what, Transport::Play | Transport::Stop) { t.accent } else { t.icon }; + let p = ui.painter(); + let tri = |dir: f32, x: f32| { + egui::Shape::convex_polygon(vec![pos2(x - 4.0 * dir, c.y - 5.0), pos2(x + 4.0 * dir, c.y), pos2(x - 4.0 * dir, c.y + 5.0)], ink, Stroke::NONE) + }; + let bar = |x: f32| egui::Shape::rect_filled(egui::Rect::from_center_size(pos2(x, c.y), vec2(2.0, 10.0)), 0.0, ink); + match what { + Transport::Play => drop(p.add(tri(1.0, c.x + 1.0))), + Transport::Stop => drop(p.rect_filled(egui::Rect::from_center_size(c, vec2(9.0, 9.0)), 1.0, ink)), + Transport::Back => { + p.add(bar(c.x + 4.0)); + p.add(tri(-1.0, c.x - 1.0)); + } + Transport::Forward => { + p.add(bar(c.x - 4.0)); + p.add(tri(1.0, c.x + 1.0)); + } + Transport::Start => { + p.add(bar(c.x - 6.0)); + p.add(tri(-1.0, c.x - 0.5)); + p.add(tri(-1.0, c.x + 5.0)); + } + Transport::End => { + p.add(tri(1.0, c.x - 5.0)); + p.add(tri(1.0, c.x + 0.5)); + p.add(bar(c.x + 6.0)); + } + } + resp.on_hover_text(tip) +} + /// The study command's parameters. pub fn study_params(s: &Session, name: &str, study: Option<&str>, steps: &str, tracks: &[Track]) -> Result { let steps: u32 = steps.trim().parse().map_err(|_| "steps must be a whole number".to_string())?; @@ -321,7 +394,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel ui.end_row(); } } - Mo::MotionStudy { name, study, steps, tracks, step, playing, shown, saved, error } => { + Mo::MotionStudy { name, study, steps, tracks, step, playing, looping, speed, shown, saved, error } => { row_label(ui, "Name"); ui.text_edit_singleline(name); ui.end_row(); @@ -336,19 +409,54 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel ui.end_row(); return enter; } - // Playback. - row_label(ui, "Step"); + // Playback: to start, step back, play/stop, step on, to end; loop and speed. + row_label(ui, "Playback"); ui.horizontal(|ui| { - if ui.button(if *playing { "■" } else { "▶" }).on_hover_text(if *playing { "Stop" } else { "Play" }).clicked() { + let last = f64::from(total); + if transport(ui, Transport::Start, "To the start").clicked() { + *step = 0.0; + *playing = false; + } + if transport(ui, Transport::Back, "Step back").clicked() { + *step = (step.round() - 1.0).max(0.0); + *playing = false; + } + let (what, tip) = if *playing { (Transport::Stop, "Stop") } else { (Transport::Play, "Play") }; + if transport(ui, what, tip).clicked() { + // Play from the start again when at the end. + if !*playing && step.round() >= last { + *step = 0.0; + } *playing = !*playing; } - ui.spacing_mut().slider_width = 220.0; - ui.add(egui::Slider::new(step, 0.0..=f64::from(total)).integer()); + if transport(ui, Transport::Forward, "Step on").clicked() { + *step = (step.round() + 1.0).min(last); + *playing = false; + } + if transport(ui, Transport::End, "To the end").clicked() { + *step = last; + *playing = false; + } + ui.add_space(6.0); + ui.checkbox(looping, "Loop"); + let names: Vec = SPEEDS.iter().map(|x| format!("{x}×")).collect(); + let refs: Vec<&str> = names.iter().map(String::as_str).collect(); + combo(ui, "ms_speed", &refs, speed); + }); + ui.end_row(); + row_label(ui, "Step"); + ui.horizontal(|ui| { + ui.spacing_mut().slider_width = 260.0; + if ui.add(egui::Slider::new(step, 0.0..=f64::from(total)).integer().show_value(false)).dragged() { + *playing = false; + } + ui.label(RichText::new(format!("{} / {total}", step.round() as u32)).color(t.text_dim)); }); ui.end_row(); if *playing { - let dt = ui.input(|i| i.stable_dt).min(0.1); - *step = (*step + f64::from(dt) * 24.0) % (f64::from(total) + 1.0); + let dt = f64::from(ui.input(|i| i.stable_dt).min(0.1)); + let fps = STEPS_PER_S * SPEEDS.get(*speed).copied().unwrap_or(1.0); + (*step, *playing) = advance(*step, dt * fps, total, *looping); ui.ctx().request_repaint(); } // Tracks: a row per keyed joint value, its keys on a strip under the steps. @@ -667,6 +775,15 @@ pub fn commands(app: &SolveApp, k: &Mo, inputs: &[SelInput]) -> Result DocSlot { /// Make tab `i` the active design. pub fn switch(app: &mut SolveApp, i: usize) { + app.home.open = false; if i == app.docs.active || i >= app.docs.slots.len() { return; } @@ -293,6 +294,25 @@ pub fn control(app: &mut SolveApp, p: &Value) -> Value { /// + for a new design. pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) { let t = Tokens::get(); + let home = Rect::from_min_size(pos2(x0, r.top() + 5.0), vec2(66.0, r.height() - 5.0)); + let response = ui.interact(home, ui.id().with("home_tab"), Sense::click()); + ui.painter().rect_filled( + home, + 5.0, + if app.home.open { + t.toolbar + } else if response.hovered() { + t.hover + } else { + Color32::from_white_alpha(10) + }, + ); + ui.painter().text(home.center(), Align2::CENTER_CENTER, "Home", FontId::proportional(13.0), if app.home.open { t.text } else { t.app_bar_text }); + crate::scenario::publish_handle("tab:home", home.center()); + if response.clicked() { + app.home.show(app.session.revision); + } + let x0 = x0 + 70.0; let info = tabs_info(app); let n = info.len(); let (first, shown, w) = layout(n, app.docs.active, x1 - x0); @@ -301,8 +321,9 @@ pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) { let mut shut: Option = None; for (i, (name, dirty)) in info.iter().enumerate().skip(first).take(shown) { let tr = Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(w - 2.0, r.height() - 5.0)); - let active = i == app.docs.active || app.docs.slots.is_empty(); + let active = !app.home.open && (i == app.docs.active || app.docs.slots.is_empty()); let resp = ui.interact(tr, ui.id().with(("doc_tab", i)), Sense::click_and_drag()); + crate::scenario::publish_handle(&format!("tab:design:{i}"), tr.center()); // The tabs are part of the title bar: dragging one moves the window, a double click // maximizes it. if app.custom_titlebar || app.integrated_titlebar { @@ -389,7 +410,6 @@ pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) { ui.painter().line_segment([c - vec2(0.0, 6.0), c + vec2(0.0, 6.0)], s); if presp.on_hover_text("New Design (Ctrl+N)").clicked() { new_design(app); - app.home.show(app.session.revision); } if let Some(i) = shut { close(app, i, false); diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index 24feb9f9..deab41d2 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -1,6 +1,6 @@ //! The start page: New Design, Open, the recent designs and the built-in samples, each with a //! picture of the design rendered from it. Shown on launch without a file and from the house -//! button; it closes when a design is opened or started, or the design changes. +//! button or the first document tab; selecting a design returns to its view. use std::collections::HashMap; @@ -24,8 +24,7 @@ pub struct HomeState { pub open: bool, /// Recent design paths, newest first (kept in the preferences). pub recent: Vec, - /// The session revision when the page opened (a change closes it). - rev: u64, + pub opened_at: std::collections::BTreeMap, thumbs: HashMap>, samples: HashMap, /// Keys asked for and not made yet. @@ -37,9 +36,8 @@ pub struct HomeState { } impl HomeState { - pub fn show(&mut self, rev: u64) { + pub fn show(&mut self, _rev: u64) { self.open = true; - self.rev = rev; } /// Put a design at the top of the recent list. @@ -47,6 +45,8 @@ impl HomeState { self.recent.retain(|p| p != path); self.recent.insert(0, path.to_string()); self.recent.truncate(MAX_RECENT); + self.opened_at.insert(path.to_string(), web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)); + self.opened_at.retain(|p, _| self.recent.contains(p)); self.thumbs.remove(path); } } @@ -179,9 +179,8 @@ pub fn open_sample(app: &mut SolveApp, i: usize) { } } -fn open_recent(app: &mut SolveApp, path: &str) { +pub fn open_recent(app: &mut SolveApp, path: &str) { if !solvecraft_engine::io::vfs::exists(path) { - app.home.recent.retain(|p| p != path); app.set_status(format!("{path} is no longer there"), true); return; } @@ -191,10 +190,6 @@ fn open_recent(app: &mut SolveApp, path: &str) { /// The page itself (in place of the toolbar, browser and viewport). pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { - if app.session.revision != app.home.rev { - app.home.open = false; - return; - } poll(app, ui.ctx()); let t = Tokens::get(); let samples = solvecraft_engine::sample::samples(); @@ -261,6 +256,19 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { } }); ui.add_space(24.0); + ui.horizontal_wrapped(|ui| { + ui.hyperlink_to("Documentation", crate::help::DOCS_URL); + ui.hyperlink_to("Report an Issue", "https://github.com/bherbruck/solvecraft/issues/new"); + ui.hyperlink_to("What's new", "https://github.com/bherbruck/solvecraft/releases/latest"); + if ui.link("Keyboard Shortcuts").clicked() { + app.keymap.open = true; + app.keymap.read_only = true; + } + if ui.link("Preferences").clicked() { + crate::prefs::open_at(app, 0); + } + }); + ui.add_space(16.0); section(ui, "Recent"); if recent.is_empty() { ui.label(RichText::new("Designs you open or save appear here.").color(t.text_dim)); @@ -269,20 +277,46 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { let Some(p) = recent.get(i) else { return }; let path = std::path::Path::new(p); let name = path.file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| p.clone()); - let folder = path.parent().map(|x| x.to_string_lossy().to_string()).unwrap_or_default(); let missing = !solvecraft_engine::io::vfs::exists(p); - let sub = if missing { + let mut sub = if missing { "missing".to_string() } else if p.starts_with("/opfs/") { // The web build keeps designs in the browser's own storage. "in this browser".to_string() } else { - folder + p.clone() }; - if card(ui, Pic::Ready(app.home.thumbs.get(p.as_str()).and_then(Option::as_ref)), &name, &sub, missing) - .on_hover_text(p.as_str()) - .clicked() - { + if let Some(time) = app.home.opened_at.get(p) { + let now = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(*time); + let seconds = now.saturating_sub(*time); + let ago = if seconds < 60 { + "just now".to_string() + } else if seconds < 3600 { + format!("{} min ago", seconds / 60) + } else if seconds < 86400 { + format!("{} h ago", seconds / 3600) + } else { + format!("{} days ago", seconds / 86400) + }; + sub.push_str(&format!("\nOpened {ago}")); + } + let response = card(ui, Pic::Ready(app.home.thumbs.get(p.as_str()).and_then(Option::as_ref)), &name, &sub, missing) + .on_hover_text(p.as_str()); + crate::scenario::publish_handle(&format!("home:recent:{i}"), response.rect.center()); + response.context_menu(|ui| { + if ui.button("Remove from list").clicked() { + app.home.recent.retain(|x| x != p); + app.home.opened_at.remove(p); + ui.close(); + } + if !cfg!(target_arch = "wasm32") && ui.button("Show in folder").clicked() { + if let Some(show) = &app.services.show_in_folder { + show(p); + } + ui.close(); + } + }); + if response.clicked() { let p = p.clone(); action = Some(Box::new(move |app: &mut SolveApp| open_recent(app, &p))); } @@ -340,7 +374,7 @@ enum Pic<'a> { /// A design card: its picture (a spinner while it renders), name and a line under it. fn card(ui: &mut egui::Ui, pic: Pic, name: &str, sub: &str, dim: bool) -> egui::Response { let t = Tokens::get(); - let size = vec2(THUMB[0] as f32 + 16.0, THUMB[1] as f32 + 62.0); + let size = vec2(THUMB[0] as f32 + 16.0, THUMB[1] as f32 + 80.0); let (r, resp) = ui.allocate_exact_size(size, Sense::click()); let hover = resp.hovered(); ui.painter().rect( diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d4093555..35a254d7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -155,6 +155,7 @@ impl Default for UiState { pub struct Services { pub pick_open: Option Option>>, pub pick_save: Option Option>>, + pub show_in_folder: Option>, /// Where the host reads the anti-aliasing preference at start (a small JSON file). pub graphics_path: Option, } @@ -304,6 +305,7 @@ impl SolveApp { "browser_collapsed": self.tree.collapsed, "browser_expanded": self.tree.expanded, "recent": self.home.recent, + "recent_opened_at": self.home.opened_at, "shortcut_box": self.sbox.prefs(), "shortcuts": self.keymap.prefs(), "toolbar": self.toolbar_custom.prefs(), @@ -319,6 +321,7 @@ impl SolveApp { pub fn load_prefs(&mut self, prefs: &str) { let Ok(v) = serde_json::from_str::(prefs) else { return }; let flag = |k: &str| v.get(k).and_then(Value::as_bool); + self.home.opened_at = v.get("recent_opened_at").and_then(|x| serde_json::from_value(x.clone()).ok()).unwrap_or_default(); if let Some(d) = flag("dark") { self.ui.dark = d; } @@ -430,6 +433,12 @@ impl SolveApp { } self.tool = None; self.home.open = false; + if matches!(id, "sketch.insert_dxf" | "sketch.insert_svg") { + if let Some(path) = self.services.pick_open.as_ref().and_then(|f| f()) { + let _ = self.run(id, json!({"path": path})); + } + return; + } if matches!(id, "file.insert_step" | "file.insert_mesh") { if let Some(p) = self.services.pick_open.as_ref().and_then(|f| f()) { self.insert_path(&p); @@ -480,7 +489,20 @@ impl SolveApp { { self.last_command = Some((spec.id.to_string(), spec.label.to_string())); } - if let Some(t) = tools::Tool::for_command(id) { + if let Some(mut t) = tools::Tool::for_command(id) { + if id == "sketch.dimension" { + t.picks = self + .session + .selection + .iter() + .filter_map(|s| match s { + solvecraft_engine::Sel::SketchCurve { id } | solvecraft_engine::Sel::SketchPoint { id } => Some(id.clone()), + _ => None, + }) + .take(2) + .collect(); + let _ = self.session.execute("select.clear", &json!({})); + } self.tool = Some(t); self.set_status(tools::hint(id), false); return; diff --git a/crates/ui-egui/src/menu_tests.rs b/crates/ui-egui/src/menu_tests.rs index b8c2b7e0..3829dd59 100644 --- a/crates/ui-egui/src/menu_tests.rs +++ b/crates/ui-egui/src/menu_tests.rs @@ -232,8 +232,10 @@ fn delete_key_handles_every_kind_and_asks_about_dependents() { app.run("edit.undo", json!({})).unwrap(); assert_eq!(app.session.doc.features.len(), n); // A face its neighbours can't close (a side of the plate): refused, nothing changes. - let b0 = app.session.model.state().bodies[0].name.clone(); - app.run("select.set", json!({"items": [{"type": "face", "body": b0, "index": 0, "point": [0, 0, 0]}]})).unwrap(); + let body = &app.session.model.state().bodies[0]; + let face = body.body.faces(0.01).unwrap().into_iter().find(|f| f.plane_normal.is_some_and(|n| n.x < -0.99)).unwrap(); + let b0 = body.name.clone(); + app.run("select.set", json!({"items": [{"type": "face", "body": b0, "index": face.index, "point": face.centroid}]})).unwrap(); crate::delete::delete_selection(&mut app); assert!(app.status.as_ref().is_some_and(|s| s.0.contains("Delete Face")), "{:?}", app.status); assert_eq!(app.session.doc.features.len(), n); diff --git a/crates/ui-egui/src/ref_images.rs b/crates/ui-egui/src/ref_images.rs index 60cad5ee..770b5d17 100644 --- a/crates/ui-egui/src/ref_images.rs +++ b/crates/ui-egui/src/ref_images.rs @@ -68,7 +68,7 @@ pub fn gpu_images(app: &SolveApp) -> Vec { .iter() .filter(|c| c.visible && c.opacity > 0.0) .filter_map(|c| { - let plane = doc.resolve_plane(&vals, &c.plane, 0).ok()?; + let plane = doc.canvas_plane(&vals, c).ok()?; let (_, version, size, rgba) = pixels(c)?; let corners = c.world_corners(&plane).map(|p| [p.x as f32, p.y as f32, p.z as f32]); Some(GpuImage { id: c.id, version, size, rgba, corners, opacity: c.opacity.clamp(0.0, 1.0) as f32 }) @@ -105,7 +105,7 @@ pub fn show(app: &SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &Proj) } let (vals, _) = doc.param_values(); for c in doc.canvases.iter().filter(|c| c.visible) { - let Ok(plane) = doc.resolve_plane(&vals, &c.plane, 0) else { continue }; + let Ok(plane) = doc.canvas_plane(&vals, c) else { continue }; let Some(tex) = texture(ui.ctx(), c) else { continue }; let [bl, br, _, tl] = c.corners(); let (u, v) = (br - bl, tl - bl); diff --git a/crates/ui-egui/src/scenario.rs b/crates/ui-egui/src/scenario.rs index 55f53c76..476ae7da 100644 --- a/crates/ui-egui/src/scenario.rs +++ b/crates/ui-egui/src/scenario.rs @@ -245,6 +245,15 @@ impl Harness { } pub fn call(&mut self, method: &str, params: Value) -> Value { + if method == "ui.resize" + && let (Some(w), Some(h)) = (params["width"].as_f64(), params["height"].as_f64()) + && w.is_finite() + && h.is_finite() + && w > 0.0 + && h > 0.0 + { + self.size = egui::vec2(w as f32, h as f32); + } let (req, _rx) = ControlRequest::new(method, params); let out = match handle(&mut self.app, &self.ctx, &req) { Outcome::Done(v) => v, diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index c928c0f3..38abfc1e 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -54,6 +54,7 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) { let br = Rect::from_center_size(pos2(x, r.center().y), vec2(26.0, 26.0)); x += 30.0; let resp = ui.interact(br, ui.id().with(("ab", icon)), Sense::click()); + crate::scenario::publish_handle(&format!("appbar:{icon}"), br.center()); if resp.hovered() { ui.painter().rect_filled(br, 4.0, Color32::from_white_alpha(30)); } @@ -148,8 +149,11 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) { .show(ctx, |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { ui.set_min_width(190.0); + ui.visuals_mut().widgets.inactive.weak_bg_fill = Color32::TRANSPARENT; + ui.visuals_mut().widgets.inactive.bg_stroke = Stroke::NONE; + ui.visuals_mut().widgets.hovered.weak_bg_fill = Tokens::get().hover; let item = |ui: &mut egui::Ui, label: &str, key: &str| { - ui.add(egui::Button::new(label).shortcut_text(key).frame(false).min_size(vec2(180.0, 22.0))).clicked() + ui.add(egui::Button::new(label).shortcut_text(key).min_size(vec2(180.0, 22.0))).clicked() }; if item(ui, "New Design", "Ctrl+N") { crate::documents::new_design(app); @@ -161,6 +165,24 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) { } close = true; } + ui.menu_button("Open Recent", |ui| { + for path in app.home.recent.clone() { + let missing = !solvecraft_engine::io::vfs::exists(&path); + let label = format!("{path}{}", if missing { " (missing)" } else { "" }); + if ui.button(label).clicked() { + crate::home::open_recent(app, &path); + close = true; + ui.close(); + } + } + ui.separator(); + if ui.button("Clear Recent").clicked() { + app.home.recent.clear(); + app.home.opened_at.clear(); + close = true; + ui.close(); + } + }); if item(ui, "Insert STEP…", "") { app.start("file.insert_step"); close = true; @@ -308,11 +330,16 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { let top = r.top() + 26.0; let mut drop: Option<(&'static str, &'static str, Option<&'static str>)> = None; let mut menu: Option<(egui::Pos2, &'static str)> = None; + let mut overflow = Vec::new(); for (panel, cmds, promote) in crate::toolbar_custom::panel_lists(&tab) { let panel: &&str = &panel; let shown = crate::toolbar_custom::buttons(app, &tab, panel, &cmds, promote); let n = shown.len().max(1); let width = (n as f32 * 40.0).max(64.0) + 8.0; + if !overflow.is_empty() || px + width > r.right() - 40.0 { + overflow.push((*panel, cmds)); + continue; + } let enabled_panel = !cmds.is_empty() || *panel == "SELECT"; // Dropped on the panel's free space: the button goes to its end. let free = Rect::from_min_size( @@ -433,6 +460,31 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { painter.line_segment([pos2(px, top + 4.0), pos2(px, top + 58.0)], Stroke::new(1.0, t.border)); px += 2.0; } + if !overflow.is_empty() { + let at = Rect::from_min_size(pos2(px.min(r.right() - 40.0), top + 4.0), vec2(38.0, 52.0)); + ui.scope_builder(egui::UiBuilder::new().max_rect(at), |ui| { + let response = ui.menu_button("»", |ui| { + for (panel, cmds) in &overflow { + ui.menu_button(*panel, |ui| { + if *panel == "SELECT" { + selection_filter(app, ui); + } + for c in cmds { + let info = c.info(&app.session); + let response = ui.add_enabled(info.enabled, egui::Button::new(c.label).shortcut_text(c.shortcut.unwrap_or(""))); + crate::scenario::publish_handle(&format!("toolbar:{}", c.id), response.rect.center()); + if response.clicked() { + app.start(c.id); + ui.close(); + } + } + }); + } + }); + crate::scenario::publish_handle("toolbar:overflow", response.response.rect.center()); + response.response.on_hover_text("More toolbar panels"); + }); + } painter.line_segment([pos2(r.left(), r.bottom() - 0.5), pos2(r.right(), r.bottom() - 0.5)], Stroke::new(1.0, t.border)); if let Some((id, panel, before)) = drop { crate::toolbar_custom::reorder(app, &tab, id, panel, before); diff --git a/crates/ui-egui/src/tools.rs b/crates/ui-egui/src/tools.rs index 6a89cdea..681f41ba 100644 --- a/crates/ui-egui/src/tools.rs +++ b/crates/ui-egui/src/tools.rs @@ -80,7 +80,7 @@ pub fn hint(id: &str) -> String { "sketch.circle.center" => "Circle: click the centre, then a point on the circle".into(), "sketch.arc.three_point" => "Arc: click start, end, then a point on the arc".into(), "sketch.arc.center_point" => "Arc: click centre, start, then end (counter-clockwise)".into(), - "sketch.dimension" => "Dimension: pick a line, circle or arc (or two points/lines)".into(), + "sketch.dimension" => "Dimension: pick one or two entities, then click to place the value".into(), "sketch.slot.center_to_center" | "sketch.slot.overall" => "Slot: click both ends, then the width".into(), _ if id.starts_with("sketch.constraint.") => "Constraint: pick the sketch entities".into(), _ => crate::sketch_tools::hint(id).unwrap_or_else(|| "Click in the sketch".into()), @@ -142,31 +142,21 @@ pub fn on_click(app: &mut SolveApp, proj: &Proj, pos: Pos2) { Kind::Dimension => { let hits = pick(app, proj, pos); let hit = hits.iter().find_map(|h| match h { - Hit::SketchPoint { id, .. } => Some((id.clone(), true)), - Hit::SketchCurve { id, .. } => Some((id.clone(), false)), + Hit::SketchPoint { id, .. } | Hit::SketchCurve { id, .. } => Some(id.clone()), _ => None, }); - if let Some((id, is_point)) = hit { - tool.picks.push(id.clone()); - let st = app.session.world_state(); - let is_line = app - .session - .active_sketch - .and_then(|s| st.sketch(s)) - .and_then(|ss| ss.sketch.curve_index(&id).and_then(|c| ss.sketch.curves.get(c).cloned())) - .map(|c| matches!(c.kind, solvecraft_engine::sketch::CurveKind::Line { .. })); - let ready = (tool.picks.len() == 1 && !is_point && is_line != Some(true)) || tool.picks.len() >= 2; - let single_line = tool.picks.len() == 1 && is_line == Some(true); - if ready || single_line { - // A single line gets its length right away; a second pick (line or point) replaces it. - if let Ok(v) = app.run("sketch.dimension", json!({"entities": tool.picks})) - && let Some(p) = v["param"].as_str() - { - // The value is edited in place, selected so typing replaces it. - crate::dim_view::edit_param(app, p); - } - tool.picks.clear(); + if let Some(id) = hit { + if !tool.picks.contains(&id) && tool.picks.len() < 2 { + tool.picks.push(id); } + } else if !tool.picks.is_empty() + && let Some((at, _)) = sketch_point_at(app, proj, pos) + && let Ok(v) = app.run("sketch.dimension", json!({"entities": tool.picks, "text_at": [at.x, at.y]})) + { + if let Some(p) = v["param"].as_str() { + crate::dim_view::edit_param(app, p); + } + tool.picks.clear(); } } } diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 244af677..45c1d11f 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -1682,6 +1682,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { app.viewport.boxsel = None; } crate::ref_images::show(app, ui, &painter, &proj); + crate::dialogs_assembly::origins(app, &painter, &proj); overlays(app, &painter, &proj); crate::dim_view::show(app, ui, &painter, &proj); crate::sketch3d::show(app, &painter, &proj); diff --git a/crates/ui-egui/tests/scenarios.rs b/crates/ui-egui/tests/scenarios.rs index 717a9042..f0ef2c06 100644 --- a/crates/ui-egui/tests/scenarios.rs +++ b/crates/ui-egui/tests/scenarios.rs @@ -31,3 +31,98 @@ fn adhoc() { panic!("{path}: {e}"); } } + +#[test] +fn angle_dimension_both_interactive_workflows() { + use serde_json::json; + for preselect in [false, true] { + let mut h = Harness::new(); + h.run(&json!([ + {"call":"engine.execute","params":{"command":"sketch.create","params":{"plane":"XY"}}}, + {"call":"engine.execute","params":{"command":"sketch.line","params":{"points":[[0,0],[40,0]]}}}, + {"call":"engine.execute","params":{"command":"sketch.line","params":{"points":[[0,0],[30,30]]}}}, + {"call":"ui.view","params":{"view":"top"}} + ])) + .unwrap(); + let picks = json!([ + {"click":{"sketch":[20,0]}}, + {"click":{"sketch":[15,15]},"shift":true} + ]); + if preselect { + h.run(&picks).unwrap(); + h.run(&json!([{ "key":"D" }])).unwrap(); + } else { + h.run(&json!([{ "key":"D" }])).unwrap(); + h.run(&picks).unwrap(); + } + h.run(&json!([ + {"click":{"sketch":[20,8]}}, + {"text":"45"}, {"key":"Enter"}, + {"expect":{"errors":0,"param":{"d1":std::f64::consts::FRAC_PI_4}}} + ])) + .unwrap(); + let sk = &h.app.session.model.state().sketches[0].sketch; + assert!(sk.constraints.iter().any(|c| matches!(c.kind, solvecraft_engine::sketch::ConstraintKind::Angle { .. }))); + } +} + +#[test] +fn home_tab_and_file_recent_open_designs() { + use serde_json::json; + let mut h = Harness::new(); + h.run(&json!([ + {"call":"engine.execute","params":{"command":"solid.box","params":{"length":10,"width":10,"height":10}}}, + {"call":"engine.execute","params":{"command":"file.save_as","params":{"path":"$DIR/recent.solvecraft"}}}, + {"click":{"handle":"tab:home"}}, + {"expect":{"home":true,"documents":1}}, + {"click":{"handle":"tab:design:0"}}, + {"expect":{"home":false,"bodies":1}}, + {"click":{"handle":"tab:home"}}, + {"click":{"handle":"home:recent:0"}}, + {"expect":{"home":false,"bodies":1,"documents":2}}, + {"click":{"handle":"appbar:folder"}}, + {"widget":"Open Recent"} + ])) + .unwrap(); + let path = h.dir.join("recent.solvecraft").to_string_lossy().into_owned(); + h.run(&json!([ + {"widget":path}, + {"expect":{"home":false,"bodies":1,"documents":3}} + ])) + .unwrap(); +} + +#[test] +fn sketch_toolbar_imports_use_file_picker() { + use serde_json::json; + let mut h = Harness::new(); + let path = h.dir.join("drawing.svg"); + std::fs::write( + &path, + r#""#, + ) + .unwrap(); + h.run(&json!([ + {"call":"engine.execute","params":{"command":"sketch.create","params":{"plane":"XY"}}}, + {"choose_file":path}, + {"start":"sketch.insert_svg"}, + {"expect":{"errors":0,"sketch_curves":{"own":4}}} + ])) + .unwrap(); +} + +#[test] +fn narrow_toolbar_reaches_collapsed_commands() { + use serde_json::json; + let mut h = Harness::new(); + h.run(&json!([ + {"call":"ui.resize","params":{"width":640,"height":800}}, + {"expect":{"shown_handles":["toolbar:overflow"]}}, + {"click":{"handle":"toolbar:overflow"}}, + {"widget":"CONSTRUCT"}, + {"expect":{"shown_handles":["toolbar:construct.plane.offset"]}}, + {"click":{"handle":"toolbar:construct.plane.offset"}}, + {"expect":{"dialog":"OffsetPlane"}} + ])) + .unwrap(); +} diff --git a/crates/ui-egui/tests/scenarios/acceptance_lid_lip_after_resize.json b/crates/ui-egui/tests/scenarios/acceptance_lid_lip_after_resize.json index 0da30c42..1cec549a 100644 --- a/crates/ui-egui/tests/scenarios/acceptance_lid_lip_after_resize.json +++ b/crates/ui-egui/tests/scenarios/acceptance_lid_lip_after_resize.json @@ -1,7 +1,4 @@ [ - { - "pending": "#40: offsets of projected geometry get new ids on recompute, the lid's lip fails" - }, { "note": "Acceptance (#27): the board grows 60 \u2192 70 mm; the lid's lip (offsets of the case's projected rim) follows." }, diff --git a/crates/ui-egui/tests/scenarios/acceptance_sensor_enclosure.json b/crates/ui-egui/tests/scenarios/acceptance_sensor_enclosure.json index 30f04daa..c5592787 100644 --- a/crates/ui-egui/tests/scenarios/acceptance_sensor_enclosure.json +++ b/crates/ui-egui/tests/scenarios/acceptance_sensor_enclosure.json @@ -87,6 +87,70 @@ } } }, + { + "call": "engine.execute", + "params": { + "command": "parameters.change", + "params": { + "name": "wall", + "expression": "2.4 mm" + } + } + }, + { + "expect": { + "errors": 0, + "bodies": 2 + } + }, + { + "call": "engine.execute", + "params": { + "command": "parameters.change", + "params": { + "name": "height", + "expression": "30 mm" + } + } + }, + { + "expect": { + "errors": 0, + "bodies": 2 + } + }, + { + "call": "engine.execute", + "params": { + "command": "parameters.change", + "params": { + "name": "height", + "expression": "25 mm" + } + } + }, + { + "expect": { + "errors": 0, + "bodies": 2 + } + }, + { + "call": "engine.execute", + "params": { + "command": "parameters.change", + "params": { + "name": "wall", + "expression": "2 mm" + } + } + }, + { + "expect": { + "errors": 0, + "bodies": 2 + } + }, { "call": "engine.execute", "params": { diff --git a/crates/ui-egui/tests/scenarios/acceptance_snap_fit_after_resize.json b/crates/ui-egui/tests/scenarios/acceptance_snap_fit_after_resize.json index d2d51a42..9c6e6e9b 100644 --- a/crates/ui-egui/tests/scenarios/acceptance_snap_fit_after_resize.json +++ b/crates/ui-egui/tests/scenarios/acceptance_snap_fit_after_resize.json @@ -1,7 +1,4 @@ [ - { - "pending": "#41: a snap fit stays at its old point when its wall moves (a floating body)" - }, { "note": "Acceptance (#27): the board grows 40 \u2192 50 mm deep; the snap fit stays on the moved front wall." }, diff --git a/crates/ui-egui/tests/scenarios/s01_box_fillet_hole_shell.json b/crates/ui-egui/tests/scenarios/s01_box_fillet_hole_shell.json index a4bb45de..45c796e3 100644 --- a/crates/ui-egui/tests/scenarios/s01_box_fillet_hole_shell.json +++ b/crates/ui-egui/tests/scenarios/s01_box_fillet_hole_shell.json @@ -50,6 +50,7 @@ ] } }, + {"click": {"sketch": [20, -8]}}, { "text": "50" }, diff --git a/crates/ui-egui/tests/scenarios/s06_edit_early_feature.json b/crates/ui-egui/tests/scenarios/s06_edit_early_feature.json index d94e483d..e0347db9 100644 --- a/crates/ui-egui/tests/scenarios/s06_edit_early_feature.json +++ b/crates/ui-egui/tests/scenarios/s06_edit_early_feature.json @@ -40,6 +40,7 @@ ] } }, + {"click": {"sketch": [20, -8]}}, { "text": "40" }, diff --git a/crates/ui-egui/tests/scenarios/s07_components_joint_drive.json b/crates/ui-egui/tests/scenarios/s07_components_joint_drive.json index 51a108d2..ce212e33 100644 --- a/crates/ui-egui/tests/scenarios/s07_components_joint_drive.json +++ b/crates/ui-egui/tests/scenarios/s07_components_joint_drive.json @@ -76,6 +76,14 @@ { "key": "Enter" }, + { + "call": "ui.set", + "params": { + "hiddenBodies": [ + "Body1" + ] + } + }, { "start": "sketch.create" }, @@ -84,6 +92,12 @@ "plane": "XZ" } }, + { + "call": "ui.set", + "params": { + "hiddenBodies": [] + } + }, { "start": "sketch.rectangle.two_point" }, @@ -99,7 +113,7 @@ "click": { "sketch": [ 50, - 10 + -10 ] } }, @@ -116,16 +130,13 @@ } }, { - "start": "solid.extrude" + "call": "ui.view", + "params": { + "view": "fit" + } }, { - "click": { - "world": [ - 40, - 0, - 5 - ] - } + "start": "solid.extrude" }, { "text": "20" @@ -199,7 +210,7 @@ "max": [ null, null, - 20 + 30 ] } } @@ -231,4 +242,4 @@ { "shot": "s07_driven" } -] \ No newline at end of file +] diff --git a/crates/ui-egui/tests/scenarios/s10_parameters_drive_model.json b/crates/ui-egui/tests/scenarios/s10_parameters_drive_model.json index 2bcb2933..65f227b4 100644 --- a/crates/ui-egui/tests/scenarios/s10_parameters_drive_model.json +++ b/crates/ui-egui/tests/scenarios/s10_parameters_drive_model.json @@ -40,6 +40,7 @@ ] } }, + {"click": {"sketch": [20, -8]}}, { "text": "40" }, diff --git a/crates/ui-egui/tests/scenarios/s11_sketch_constraints.json b/crates/ui-egui/tests/scenarios/s11_sketch_constraints.json index 9a72c5f8..1cdd6062 100644 --- a/crates/ui-egui/tests/scenarios/s11_sketch_constraints.json +++ b/crates/ui-egui/tests/scenarios/s11_sketch_constraints.json @@ -95,6 +95,14 @@ ] } }, + { + "click": { + "sketch": [ + 20, + -8 + ] + } + }, { "text": "40" }, @@ -109,6 +117,14 @@ ] } }, + { + "click": { + "sketch": [ + 48, + 12 + ] + } + }, { "text": "25" }, @@ -123,6 +139,14 @@ ] } }, + { + "click": { + "sketch": [ + 30, + 20 + ] + } + }, { "text": "10" }, @@ -219,4 +243,4 @@ { "shot": "s11_done" } -] \ No newline at end of file +] diff --git a/crates/ui-egui/tests/scenarios/s20_section_with_joint_drive.json b/crates/ui-egui/tests/scenarios/s20_section_with_joint_drive.json index ef953588..aaefdc34 100644 --- a/crates/ui-egui/tests/scenarios/s20_section_with_joint_drive.json +++ b/crates/ui-egui/tests/scenarios/s20_section_with_joint_drive.json @@ -76,6 +76,14 @@ { "key": "Enter" }, + { + "call": "ui.set", + "params": { + "hiddenBodies": [ + "Body1" + ] + } + }, { "start": "sketch.create" }, @@ -84,6 +92,12 @@ "plane": "XZ" } }, + { + "call": "ui.set", + "params": { + "hiddenBodies": [] + } + }, { "start": "sketch.rectangle.two_point" }, @@ -99,7 +113,7 @@ "click": { "sketch": [ 50, - 10 + -10 ] } }, @@ -116,16 +130,13 @@ } }, { - "start": "solid.extrude" + "call": "ui.view", + "params": { + "view": "fit" + } }, { - "click": { - "world": [ - 40, - 0, - 5 - ] - } + "start": "solid.extrude" }, { "text": "20" @@ -236,4 +247,4 @@ { "shot": "s20_section_driven" } -] \ No newline at end of file +] diff --git a/crates/ui-egui/tests/scenarios/s23_configurations.json b/crates/ui-egui/tests/scenarios/s23_configurations.json index c1aa12b4..3d097ae4 100644 --- a/crates/ui-egui/tests/scenarios/s23_configurations.json +++ b/crates/ui-egui/tests/scenarios/s23_configurations.json @@ -43,6 +43,7 @@ ] } }, + {"click": {"sketch": [20, -8]}}, { "text": "40" }, diff --git a/crates/ui-egui/tests/scenarios/s26_drive_joint_typed.json b/crates/ui-egui/tests/scenarios/s26_drive_joint_typed.json index cc01f43d..1785b799 100644 --- a/crates/ui-egui/tests/scenarios/s26_drive_joint_typed.json +++ b/crates/ui-egui/tests/scenarios/s26_drive_joint_typed.json @@ -76,6 +76,14 @@ { "key": "Enter" }, + { + "call": "ui.set", + "params": { + "hiddenBodies": [ + "Body1" + ] + } + }, { "start": "sketch.create" }, @@ -84,6 +92,12 @@ "plane": "XZ" } }, + { + "call": "ui.set", + "params": { + "hiddenBodies": [] + } + }, { "start": "sketch.rectangle.two_point" }, @@ -99,7 +113,7 @@ "click": { "sketch": [ 50, - 10 + -10 ] } }, @@ -116,16 +130,13 @@ } }, { - "start": "solid.extrude" + "call": "ui.view", + "params": { + "view": "fit" + } }, { - "click": { - "world": [ - 40, - 0, - 5 - ] - } + "start": "solid.extrude" }, { "text": "20" @@ -240,4 +251,4 @@ { "shot": "s26_drive_typed" } -] \ No newline at end of file +] diff --git a/crates/ui-egui/tests/scenarios/s32_cut_in_active_component.json b/crates/ui-egui/tests/scenarios/s32_cut_in_active_component.json index 0df6a84d..e4377d17 100644 --- a/crates/ui-egui/tests/scenarios/s32_cut_in_active_component.json +++ b/crates/ui-egui/tests/scenarios/s32_cut_in_active_component.json @@ -102,7 +102,7 @@ "click": { "sketch": [ 52, - 10 + -10 ] } }, @@ -187,7 +187,7 @@ "click": { "sketch": [ 48, - 25 + 15 ] } }, @@ -239,4 +239,4 @@ { "shot": "s27_cut" } -] \ No newline at end of file +] diff --git a/crates/ui-egui/tests/scenarios/s37_browser_chevrons.json b/crates/ui-egui/tests/scenarios/s37_browser_chevrons.json index 83bcc0db..124b89f4 100644 --- a/crates/ui-egui/tests/scenarios/s37_browser_chevrons.json +++ b/crates/ui-egui/tests/scenarios/s37_browser_chevrons.json @@ -1,7 +1,4 @@ [ - { - "pending": "every chevron works; the browser has no Joints folder (solvecraft-shell)" - }, { "note": "Browser chevrons expand and collapse every kind of node (folders, components, groups, the origin, named views, document settings). Model: two components with a body each, a joint, a sketch, an offset plane, a named view." }, @@ -82,6 +79,14 @@ { "key": "Enter" }, + { + "call": "ui.set", + "params": { + "hiddenBodies": [ + "Body1" + ] + } + }, { "start": "sketch.create" }, @@ -90,6 +95,12 @@ "plane": "XZ" } }, + { + "call": "ui.set", + "params": { + "hiddenBodies": [] + } + }, { "start": "sketch.rectangle.two_point" }, @@ -105,7 +116,7 @@ "click": { "sketch": [ 50, - 10 + -10 ] } }, @@ -122,16 +133,13 @@ } }, { - "start": "solid.extrude" + "call": "ui.view", + "params": { + "view": "fit" + } }, { - "click": { - "world": [ - 40, - 0, - 5 - ] - } + "start": "solid.extrude" }, { "text": "20" diff --git a/crates/ui-egui/tests/scenarios/s43_root_sketch_on_moved_component_face.json b/crates/ui-egui/tests/scenarios/s43_root_sketch_on_moved_component_face.json new file mode 100644 index 00000000..5f2b316e --- /dev/null +++ b/crates/ui-egui/tests/scenarios/s43_root_sketch_on_moved_component_face.json @@ -0,0 +1,103 @@ +[ + { + "note": "User bug 1/4 (\"big square\"): with the root active, Create Sketch on a moved component's face puts the sketch on the face where it is shown, its projected loop on the face's edges (not at the component's own coordinates)." + }, + { + "call": "engine.execute", + "params": { + "command": "component.create", + "params": { + "name": "C" + } + } + }, + { + "call": "engine.execute", + "params": { + "command": "solid.box", + "params": { + "length": 40, + "width": 30, + "height": 20 + } + } + }, + { + "call": "engine.execute", + "params": { + "command": "component.activate", + "params": { + "component": "root" + } + } + }, + { + "call": "engine.execute", + "params": { + "command": "occurrence.move", + "params": { + "occurrence": "C:1", + "translate": [ + 100, + 0, + 0 + ] + } + } + }, + { + "call": "ui.view", + "params": { + "view": "fit" + } + }, + { + "start": "sketch.create" + }, + { + "click": { + "world": [ + 120, + 15, + 20 + ] + } + }, + { + "expect": { + "sketching": true + } + }, + { + "expect": { + "sketch_curves": { + "linked": 4, + "own": 0 + } + } + }, + { + "expect": { + "sketch_world": { + "sketch": "Sketch1", + "point": "p1", + "at": [ + 140, + 0, + 20 + ] + } + } + }, + { + "shot": "s43_root_sketch_on_moved_face" + }, + { + "start": "sketch.finish" + }, + { + "expect": { + "errors": 0 + } + } +] \ No newline at end of file diff --git a/crates/ui-egui/tests/scenarios/sketch_on_moved_component_face.json b/crates/ui-egui/tests/scenarios/sketch_on_moved_component_face.json index 5fe13d6a..8de5af8e 100644 --- a/crates/ui-egui/tests/scenarios/sketch_on_moved_component_face.json +++ b/crates/ui-egui/tests/scenarios/sketch_on_moved_component_face.json @@ -1,7 +1,4 @@ [ - { - "pending": "a root-active sketch on a face of a moved component lands in body-local coordinates (solvecraft-dialogs)" - }, { "note": "A box in a new component, the component moved +100 in x, the root active: a sketch on the box's top face projects the face's edges where the face is (x 100-140), not at the body's own coordinates (x 0-40)." }, diff --git a/crates/ui-egui/tests/scenarios/view_cube_y_up.json b/crates/ui-egui/tests/scenarios/view_cube_y_up.json index aa6d1967..98b19040 100644 --- a/crates/ui-egui/tests/scenarios/view_cube_y_up.json +++ b/crates/ui-egui/tests/scenarios/view_cube_y_up.json @@ -1,7 +1,7 @@ [ - {"note": "With Y up (#3) the view cube turns with the world: its TOP is world +Y and clicking it looks down −Y in the standard plan orientation; FRONT looks along −Z."}, + {"note": "With Y up (#3) the view cube turns with the world: its TOP is world +Y and clicking it looks down −Y in the standard plan orientation; FRONT looks along −Z."}, {"call": "engine.execute", "params": {"command": "solid.box", "params": {"length": 40, "width": 30, "height": 20}}}, - {"call": "ui.set", "params": {"upAxis": "y"}}, + {"call": "ui.prefs", "params": {"up_axis": "y"}}, {"call": "ui.view", "params": {"view": "home"}}, {"move": {"cube": [0, 0, 1]}}, {"expect": {"shown_handles": ["cube_hover:top"]}}, diff --git a/docs/wip-reconciliation.md b/docs/wip-reconciliation.md new file mode 100644 index 00000000..40559e11 --- /dev/null +++ b/docs/wip-reconciliation.md @@ -0,0 +1,22 @@ +# WIP branch reconciliation (#6) + +The branches predate the dotted command IDs. Their remaining changes were ported through +`crates/engine/src/legacy_ids.rs` and reconciled with the current implementation. + +| Branches | Result | +|---|---| +| `solvecraft-sk`, `solvecraft-d2` | Ported `62d22e2` and `e0b3138`: sketches/projections see placed components; cache keys include placements; moved feature dialogs resolve world picks; Joints uses Browser rows. | +| `solvecraft-b4` | Ported `2c18dac`: canvases carry component ownership and named joint origins follow component placements. | +| `solvecraft-asm` | Ported `bc05805`: joint snap feedback, Drive limits, joint menus, and Motion Study editing/playback controls. | +| `solvecraft-shell`, `solvecraft-shell-b` | Browser chevrons, shared occurrence Move triad, toolbar customization, workspace switcher and status labels are already present on main. Their scenarios remain active; the Joints chevron scenario is now active too. | +| `solvecraft-kdev3`, `solvecraft-kdev`, `solvecraft-kdev2` | Previously landed as `67c79ec`; the old refs were already removed. | +| `solvecraft-kdev4` | The earlier robustness, shell, blends, wasm and measurement work is already present. Ported the remaining `9f98449` stacked-prism sewing and repaired curved-boundary face picking. | +| `solvecraft-params(-build)` | Previously landed as `517b7a9`. | + +The source WIP refs are retained until this batch merges. Delete them only after the merged +main contains the reconciled changes and passes CI. + +Regression coverage includes root sketches on translated and rotated components, subsequent +component moves, canvas save/reopen, joint origins during pending moves, moved feature dialog +refill, Browser chevrons, sample building, and the UI scenarios. `cargo xtask ci` covers native +tests, wasm, layering and asset attribution. From 96ee326424e8cebe348f1469d934fcc1b2168b7f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 10 Oct 2026 16:18:23 -0400 Subject: [PATCH 04/15] ci: drop pages: write permission from Flatpak repository build job --- .github/workflows/flatpak-pages.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/flatpak-pages.yml b/.github/workflows/flatpak-pages.yml index 5bc2ef96..dfd48416 100644 --- a/.github/workflows/flatpak-pages.yml +++ b/.github/workflows/flatpak-pages.yml @@ -23,7 +23,6 @@ jobs: runs-on: ubuntu-24.04 permissions: contents: read - pages: write env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }} From e042a7b555ac098c3b83bf313dec740017404306 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Sat, 10 Oct 2026 17:07:18 -0400 Subject: [PATCH 05/15] ci: cache native Linux test compilation with sccache (#93) * ci: cache native test compilation with sccache * ci: preserve workflow line endings --- .github/workflows/ci.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5697ded6..e0fc901d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,17 +98,29 @@ jobs: if: needs.changes.outputs.native == 'true' runs-on: ubuntu-24.04 timeout-minutes: 90 + env: + CARGO_INCREMENTAL: "0" + RUSTC_WRAPPER: sccache + SCCACHE_GHA_ENABLED: "true" + SCCACHE_IGNORE_SERVER_IO_ERROR: "1" steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: native-tests + - name: Set up compiler cache + uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 + with: + version: v0.16.0 - name: Compile tests run: cargo test --workspace --profile ci --locked --no-run - name: Run tests # Every test binary at once (cargo test runs them one after another). run: cargo xtask test --locked + - name: Compiler cache statistics + if: always() + run: sccache --show-stats # Preserve the existing check name for branch protection, including docs-only PRs. ci: From acf4669f73a3aa58c575d4b125f5ec547ae92a18 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Sat, 10 Oct 2026 17:13:36 -0400 Subject: [PATCH 06/15] fix: select complete circular rims and curved surfaces in 3D Group kernel pieces across circular rims and hidden surface seams for hovering, selection, dialogs, and measurement. Preserve physical references and cover full-item toggling and picking with regression tests. Fixes #88 --- crates/engine/src/cmd/measure_sel.rs | 59 +++++- crates/geom/src/mesh.rs | 65 +++++++ crates/geom/src/tests.rs | 40 ++++ crates/ui-egui/src/dialogs.rs | 15 +- crates/ui-egui/src/selection.rs | 15 +- crates/ui-egui/src/viewport.rs | 264 +++++++++++++++++++++++++-- 6 files changed, 424 insertions(+), 34 deletions(-) diff --git a/crates/engine/src/cmd/measure_sel.rs b/crates/engine/src/cmd/measure_sel.rs index 95dc2dc9..9c978dfc 100644 --- a/crates/engine/src/cmd/measure_sel.rs +++ b/crates/engine/src/cmd/measure_sel.rs @@ -55,13 +55,13 @@ fn densify(pts: &[Vec3]) -> Vec { /// The mesh edge a selection means: by index if it still passes through the point, otherwise /// the nearest. -fn edge_points(m: &Mesh, index: usize, point: Vec3) -> Option> { +fn edge_index(m: &Mesh, index: usize, point: Vec3) -> Option { let near = |e: &Vec| e.windows(2).map(|w| point.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min); let tol = m.bounds().diagonal().max(1.0) * 1e-6; - if let Some(e) = m.edges.get(index).filter(|e| near(e) < tol) { - return Some(e.clone()); + if m.edges.get(index).is_some_and(|e| near(e) < tol) { + return Some(index); } - m.edges.iter().min_by(|a, b| near(a).total_cmp(&near(b))).cloned() + m.edges.iter().enumerate().min_by(|(_, a), (_, b)| near(a).total_cmp(&near(b))).map(|(i, _)| i) } fn face_tris(m: &Mesh, f: usize) -> Vec<[Vec3; 3]> { @@ -75,15 +75,23 @@ fn describe(s: &Session, sel: &Sel) -> Option<(Geo, Option, Value)> { Sel::Vertex { point, .. } => (Geo::Points(vec![*point]), None, json!({"type": "vertex", "position": point})), Sel::Edge { body, index, point } => { let m = st.body(body)?.mesh(); - let pts = edge_points(&m, *index, *point)?; + let index = edge_index(&m, *index, *point)?; + let pts = m.edges.get(index)?; + let rim = m.circular_rim(index); + if rim.len() > 1 { + let pieces: Vec<&Vec> = rim.iter().filter_map(|&i| m.edges.get(i)).collect(); + let len: f64 = pieces.iter().map(|p| polyline_len(p)).sum(); + let points = pieces.iter().flat_map(|p| densify(p)).collect(); + return Some((Geo::Points(points), None, json!({"type": "edge", "length_mm": len}))); + } let (a, b) = (*pts.first()?, *pts.last()?); let straight = (b - a).normalized().filter(|d| pts.iter().all(|p| (*p - a).cross(*d).len() < 1e-6 * (1.0 + a.dist(b)))); - let len = polyline_len(&pts); - (Geo::Points(densify(&pts)), straight, json!({"type": "edge", "length_mm": len})) + let len = polyline_len(pts); + (Geo::Points(densify(pts)), straight, json!({"type": "edge", "length_mm": len})) } Sel::Face { body, index, .. } => { let m = st.body(body)?.mesh(); - let tris = face_tris(&m, *index); + let tris = m.surface_patch(*index).into_iter().flat_map(|f| face_tris(&m, f)).collect::>(); let area: f64 = tris.iter().map(|[a, b, c]| (*b - *a).cross(*c - *a).len() * 0.5).sum(); let normals: Vec = tris.iter().filter_map(|[a, b, c]| (*b - *a).cross(*c - *a).normalized()).collect(); let planar = normals.first().copied().filter(|n0| normals.iter().all(|n| n.dot(*n0) > 1.0 - 1e-6)); @@ -150,10 +158,41 @@ fn closest(a: &Geo, b: &Geo) -> Option<(Vec3, Vec3)> { pub(crate) fn measure_items(s: &Session, items: &Value) -> Result { let cmd = "inspect.measure"; let list = items.as_array().ok_or_else(|| bad(cmd, "`items` must be a list of selections"))?; - if list.is_empty() || list.len() > 2 { + if list.is_empty() || list.len() > 1000 { return Err(bad(cmd, "measure one or two items")); } - let sels: Vec = list.iter().map(|v| serde_json::from_value::(v.clone()).map_err(|e| bad(cmd, e.to_string()))).collect::>()?; + let st = s.world_state(); + let mut sels = Vec::new(); + for value in list { + let mut sel = serde_json::from_value::(value.clone()).map_err(|e| bad(cmd, e.to_string()))?; + // Two kernel halves of one visible item are one measurement target. + match &mut sel { + Sel::Edge { body, index, point } => { + if let Some(b) = st.body(body) { + let m = b.mesh(); + let resolved = edge_index(&m, *index, *point).unwrap_or(*index); + *index = m.circular_rim(resolved).first().copied().unwrap_or(resolved); + } + } + Sel::Face { body, index, .. } => { + if let Some(b) = st.body(body) { + *index = b.mesh().surface_patch(*index).first().copied().unwrap_or(*index); + } + } + _ => {} + } + let duplicate = sels.iter().any(|x| match (x, &sel) { + (Sel::Edge { body: a, index: i, .. }, Sel::Edge { body: b, index: j, .. }) + | (Sel::Face { body: a, index: i, .. }, Sel::Face { body: b, index: j, .. }) => a == b && i == j, + _ => false, + }); + if !duplicate { + sels.push(sel); + } + if sels.len() > 2 { + return Err(bad(cmd, "measure one or two items")); + } + } let mut described = Vec::new(); for x in &sels { described.push(describe(s, x).ok_or_else(|| bad(cmd, "an item can't be measured (pick vertices, edges, faces or bodies)"))?); diff --git a/crates/geom/src/mesh.rs b/crates/geom/src/mesh.rs index 32c3373a..296f4808 100644 --- a/crates/geom/src/mesh.rs +++ b/crates/geom/src/mesh.rs @@ -82,6 +82,71 @@ impl Mesh { pub fn face_edges(&self, f: u32) -> Vec { self.edge_faces.iter().enumerate().filter(|(_, fs)| fs.contains(&f)).map(|(i, _)| i).collect() } + /// Pieces of one surface joined across hidden seams, including `face` itself. + pub fn surface_patch(&self, face: usize) -> Vec { + let mut patch = vec![face]; + let mut todo = vec![face]; + while let Some(f) = todo.pop() { + for (ei, fs) in self.edge_faces.iter().enumerate() { + if !self.seams.get(ei).copied().unwrap_or(false) || !fs.iter().any(|x| *x as usize == f) { + continue; + } + for &next in fs { + let next = next as usize; + if !patch.contains(&next) { + patch.push(next); + todo.push(next); + } + } + } + } + patch.sort_unstable(); + patch + } + + /// A complete circular rim may be split into several kernel edges. Keep its pieces + /// together for picking, without joining other tangent curves or changing edge indices. + pub fn circular_rim(&self, edge: usize) -> Vec { + let single = vec![edge]; + let Some(p) = self.edges.get(edge).filter(|p| p.len() >= 3) else { return single }; + let (Some(&a), Some(&b), Some(&c)) = (p.first(), p.get(p.len() / 2), p.last()) else { return single }; + let (u, v) = (b - a, c - a); + let n = u.cross(v); + if !(n.len2() > u.len2() * v.len2() * 1e-12) { + return single; + } + let center = a + (v.cross(n) * u.len2() + n.cross(u) * v.len2()) / (2.0 * n.len2()); + let radius = center.dist(a); + let normal = n.normalized().unwrap_or(Vec3::ZERO); + let tol = (radius * 1e-6).max(1e-9); + let mut rim = self.tangent_chain(edge, 2f64.to_radians()); + if rim.len() < 2 || !radius.is_finite() { + return single; + } + // All samples must lie on this circle, and every end must join exactly one other + // piece. Open arcs, ellipses, and branching tangent chains stay separate. + for &i in &rim { + let Some(points) = self.edges.get(i) else { return single }; + if points.iter().any(|q| !q.is_finite() || (q.dist(center) - radius).abs() > tol || (*q - center).dot(normal).abs() > tol) { + return single; + } + for end in [points.first(), points.last()].into_iter().flatten() { + let joins = rim + .iter() + .filter(|&&j| j != i) + .filter_map(|&j| self.edges.get(j)) + .flat_map(|q| [q.first(), q.last()]) + .flatten() + .filter(|q| q.dist(*end) < tol) + .count(); + if joins != 1 { + return single; + } + } + } + rim.sort_unstable(); + rim + } /// The edges reached from `e` through vertices where the next edge continues smoothly /// (angle between the end tangents below `max_angle`), including `e` itself. pub fn tangent_chain(&self, e: usize, max_angle: f64) -> Vec { diff --git a/crates/geom/src/tests.rs b/crates/geom/src/tests.rs index af1b6f68..3e996d14 100644 --- a/crates/geom/src/tests.rs +++ b/crates/geom/src/tests.rs @@ -84,6 +84,46 @@ fn tangent_chains_follow_smooth_edges() { assert_eq!(m.face_edges(1), vec![1, 2]); } +#[test] +fn circular_rims_group_only_closed_cocircular_edges() { + let arc = |start: f64, sweep: f64, yscale: f64| -> Vec { + (0..=32) + .map(|i| { + let a = start + sweep * i as f64 / 32.0; + Vec3::new(10.0 * a.cos(), yscale * 10.0 * a.sin(), 5.0) + }) + .collect() + }; + let pi = std::f64::consts::PI; + let mut m = Mesh { + positions: vec![Vec3::new(-10.0, -10.0, 5.0), Vec3::new(10.0, 10.0, 5.0)], + edges: vec![arc(0.0, pi, 1.0), arc(2.0 * pi, -pi, 1.0)], + ..Default::default() + }; + assert_eq!(m.circular_rim(0), vec![0, 1]); + assert_eq!(m.circular_rim(1), vec![0, 1]); + // A hidden seam cannot form a selectable rim. + m.seams = vec![false, true]; + assert_eq!(m.circular_rim(0), vec![0]); + m.seams.clear(); + m.edges[1] = arc(pi, pi / 2.0, 1.0); + assert_eq!(m.circular_rim(0), vec![0]); + // A closed, smooth ellipse is not a circle. + m.edges = vec![arc(0.0, pi, 0.7), arc(pi, pi, 0.7)]; + assert_eq!(m.circular_rim(0), vec![0]); + assert_eq!(m.circular_rim(99), vec![99]); +} + +#[test] +fn surface_patches_cross_only_hidden_seams() { + let mut m = Mesh { edge_faces: vec![vec![0, 1], vec![1, 2], vec![2, 3]], seams: vec![true, true, false], ..Default::default() }; + assert_eq!(m.surface_patch(0), vec![0, 1, 2]); + assert_eq!(m.surface_patch(2), vec![0, 1, 2]); + assert_eq!(m.surface_patch(3), vec![3]); + m.seams.clear(); + assert_eq!(m.surface_patch(0), vec![0]); +} + #[test] fn cubic_and_conic_segments_measure_exactly() { use crate::{Loop2, Seg2, Vec2}; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d1382537..7f3f83ef 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -480,10 +480,12 @@ impl Dialog { .collect::>() }) .unwrap_or_else(|| vec![sel.clone()]), + (Kind::Measure { .. }, _) => vec![crate::viewport::measurement_selection(s, sel)], + _ if inp.multi => crate::viewport::grouped_selection(s, sel), _ => vec![sel.clone()], }; for x in picked { - if !inp.items.contains(&x) { + if !inp.items.iter().any(|y| selection::same_item(y, &x)) { inp.items.push(x); } } @@ -631,8 +633,11 @@ impl Dialog { /// A pick in the viewport (already accepted by the active input). pub fn pick(&mut self, s: &Session, sel: Sel) { + let measure = matches!(self.kind, Kind::Measure { .. }); + let sel = if measure { crate::viewport::measurement_selection(s, &sel) } else { sel }; let chain = matches!(self.kind, Kind::Fillet { chain: true, .. }); - let mut picked = vec![sel.clone()]; + let mut picked = + if !measure && self.active_input().is_some_and(|i| i.multi) { crate::viewport::grouped_selection(s, &sel) } else { vec![sel.clone()] }; // Tangent chain: an edge brings the edges that continue it smoothly. if chain && let Sel::Edge { body, index, .. } = &sel @@ -667,13 +672,15 @@ impl Dialog { } /// Box selection result: replace (or add to) the active input. - pub fn take_box(&mut self, sels: Vec, add: bool) { + pub fn take_box(&mut self, s: &Session, sels: Vec, add: bool) { + let measure = matches!(self.kind, Kind::Measure { .. }); let Some(inp) = self.inputs.get_mut(self.active) else { return }; if !add { inp.items.clear(); } for x in sels { - if fits(inp.accept, &x) && !inp.items.contains(&x) { + let x = if measure { crate::viewport::measurement_selection(s, &x) } else { x }; + if fits(inp.accept, &x) && !inp.items.iter().any(|y| selection::same_item(y, &x)) { inp.items.push(x); } } diff --git a/crates/ui-egui/src/selection.rs b/crates/ui-egui/src/selection.rs index 413f660d..cfbfe9fe 100644 --- a/crates/ui-egui/src/selection.rs +++ b/crates/ui-egui/src/selection.rs @@ -8,6 +8,15 @@ use solvecraft_engine::geom::Vec3; use crate::viewport::Hit; +/// Model picks retain an on-surface point, but their identity is the body and topology index. +pub(crate) fn same_item(a: &Sel, b: &Sel) -> bool { + match (a, b) { + (Sel::Edge { body: a, index: i, .. }, Sel::Edge { body: b, index: j, .. }) + | (Sel::Face { body: a, index: i, .. }, Sel::Face { body: b, index: j, .. }) => a == b && i == j, + _ => a == b, + } +} + /// What a selection input accepts (bit set). pub type Accept = u16; pub const PROFILES: Accept = 1; @@ -73,11 +82,11 @@ impl SelInput { return; } // A pick of something already in (or a chain fully in) takes it out. - if picked.iter().all(|p| self.items.contains(p)) { - self.items.retain(|x| !picked.contains(x)); + if picked.iter().all(|p| self.items.iter().any(|x| same_item(x, p))) { + self.items.retain(|x| !picked.iter().any(|p| same_item(x, p))); } else { for p in picked { - if !self.items.contains(&p) { + if !self.items.iter().any(|x| same_item(x, &p)) { self.items.push(p); } } diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 45c1d11f..d436dd90 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -1091,7 +1091,14 @@ fn build_highlight(app: &SolveApp) -> GpuScene { let t = Tokens::get(); let mut sc = GpuScene::default(); let st = app.session.world_state(); - let sel = app.highlighted(); + let mut sel = Vec::new(); + for item in app.highlighted() { + for piece in grouped_selection(&app.session, &item) { + if !sel.iter().any(|x| crate::selection::same_item(x, &piece)) { + sel.push(piece); + } + } + } let hover = app.viewport.hover.as_ref(); let size = origin_size(app.cam.half_height()); // Origin planes and construction planes. @@ -1242,9 +1249,12 @@ fn build_highlight(app: &SolveApp) -> GpuScene { if let Some(b) = st.body(body) { let k = t.hover_face_lift; let c = body_rgb(app, body); - face_tris(&mut sc, &b.mesh(), *index, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true); let m = b.mesh(); - for e in m.face_edges(u32::try_from(*index).unwrap_or(u32::MAX)) { + let patch = m.surface_patch(*index); + for &f in &patch { + face_tris(&mut sc, &m, f, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true); + } + for (e, _) in m.edge_faces.iter().enumerate().filter(|(_, fs)| fs.iter().any(|f| patch.contains(&(*f as usize)))) { if let Some(p) = m.edges.get(e) && !m.seams.get(e).copied().unwrap_or(false) { @@ -1254,12 +1264,13 @@ fn build_highlight(app: &SolveApp) -> GpuScene { } } Some(Hit::Edge { body, index, mid }) => { - if let Some(b) = st.body(body) - && let Some(e) = edge_of(&b.mesh(), *index, *mid) - { - let selected = sel.iter().any(|x| matches!(x, Sel::Edge { point, .. } if point.dist(*mid) < 1e-9)); - if !selected { - edge_lines(&mut sc, e, t.hover_edge, t.hover_edge_halo, 2.0); + if let Some(b) = st.body(body) { + let m = b.mesh(); + for i in m.circular_rim(*index) { + let selected = sel.iter().any(|x| matches!(x, Sel::Edge { body: b, index: j, .. } if b == body && *j == i)); + if !selected && let Some(e) = if i == *index { edge_of(&m, i, *mid) } else { m.edges.get(i) } { + edge_lines(&mut sc, e, t.hover_edge, t.hover_edge_halo, 2.0); + } } } } @@ -1286,7 +1297,7 @@ fn body_rgb(app: &SolveApp, name: &str) -> (u8, u8, u8) { (c.r(), c.g(), c.b()) } -fn polyline_mid(pts: &[Vec3]) -> Vec3 { +pub(crate) fn polyline_mid(pts: &[Vec3]) -> Vec3 { let len: f64 = pts.windows(2).map(|w| w[0].dist(w[1])).sum(); let mut acc = 0.0; for w in pts.windows(2) { @@ -1730,12 +1741,13 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { fn select(app: &mut SolveApp, sel: Option, add: bool) { match sel { Some(x) => { - let item = serde_json::to_value(&x).unwrap_or_default(); - if add && app.session.selection.contains(&x) { - let rest: Vec = app.session.selection.iter().filter(|y| **y != x).cloned().collect(); + let items = grouped_selection(&app.session, &x); + if add && items.iter().all(|x| app.session.selection.iter().any(|y| crate::selection::same_item(x, y))) { + let rest: Vec = + app.session.selection.iter().filter(|y| !items.iter().any(|x| crate::selection::same_item(x, y))).cloned().collect(); let _ = app.run("select.set", json!({ "items": rest })); } else { - let _ = app.run("select.set", json!({"items": [item], "add": add})); + let _ = app.run("select.set", json!({"items": items, "add": add})); } } None if !add => { @@ -1745,6 +1757,75 @@ fn select(app: &mut SolveApp, sel: Option, add: bool) { } } +/// Expand a visible rim or surface into the kernel pieces a command needs. The original +/// face pick keeps its point (used by tools such as Hole and construction geometry). +pub(crate) fn grouped_selection(s: &solvecraft_engine::Session, sel: &Sel) -> Vec { + let st = s.world_state(); + match sel { + Sel::Edge { body, index, .. } => { + if let Some(b) = st.body(body) { + let m = b.mesh(); + return m + .circular_rim(*index) + .into_iter() + .filter_map(|i| m.edges.get(i).map(|e| Sel::Edge { body: body.clone(), index: i, point: polyline_mid(e) })) + .collect(); + } + } + Sel::Face { body, index, .. } => { + if let Some(b) = st.body(body) { + let m = b.mesh(); + let mut patch = m.surface_patch(*index); + patch.sort_by_key(|i| usize::from(i != index)); + return patch + .into_iter() + .filter_map(|i| { + if i == *index { + return Some(sel.clone()); + } + m.triangles.iter().zip(&m.tri_face).find_map(|(t, f)| { + if *f as usize != i { + return None; + } + let [a, b, c] = m.tri(t)?; + Some(Sel::Face { body: body.clone(), index: i, point: (a + b + c) / 3.0 }) + }) + }) + .collect(); + } + } + _ => {} + } + vec![sel.clone()] +} + +/// Measure keeps one entry per visible item, rather than using its two slots for kernel halves. +pub(crate) fn measurement_selection(s: &solvecraft_engine::Session, sel: &Sel) -> Sel { + let pieces = grouped_selection(s, sel); + let mut representative = sel.clone(); + match &mut representative { + Sel::Edge { index, .. } => { + if let Some(Sel::Edge { index: i, .. }) = pieces.first() { + *index = *i; + } + } + Sel::Face { index, .. } => { + if let Some(i) = pieces + .iter() + .filter_map(|p| match p { + Sel::Face { index, .. } => Some(*index), + _ => None, + }) + .min() + { + *index = i; + } + } + _ => {} + } + representative +} + /// What a box selects: sketch entities while sketching, otherwise what the open dialog's /// active input takes (edges, faces or bodies), otherwise bodies. fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) { @@ -1794,7 +1875,9 @@ fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) { let m = b.mesh(); if accept & EDGES != 0 { for (ei, e) in m.edges.iter().enumerate() { - if !m.seams.get(ei).copied().unwrap_or(false) && bx.takes(&to2(e)) { + let rim = m.circular_rim(ei); + let takes = if bx.crossing { bx.takes(&to2(e)) } else { rim.iter().all(|&i| m.edges.get(i).is_some_and(|p| bx.takes(&to2(p)))) }; + if !m.seams.get(ei).copied().unwrap_or(false) && takes { out.push(Sel::Edge { body: b.name.clone(), index: ei, point: polyline_mid(e) }); } } @@ -1811,8 +1894,15 @@ fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) { .find(|(_, tf)| **tf as usize == f) .and_then(|(t, _)| m.tri(t)) .map(|[a, bb, c]| (a + bb + c) / 3.0); + let takes = bx.takes(&to2(&pts)) + && (bx.crossing + || m.surface_patch(f).iter().all(|&face| { + m.face_edges(u32::try_from(face).unwrap_or(u32::MAX)) + .iter() + .all(|&e| m.edges.get(e).is_some_and(|p| bx.takes(&to2(p)))) + })); if let Some(point) = point - && bx.takes(&to2(&pts)) + && takes { out.push(Sel::Face { body: b.name.clone(), index: f, point }); } @@ -1837,8 +1927,17 @@ fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) { } } } + let mut grouped = Vec::new(); + for item in out { + for piece in grouped_selection(&app.session, &item) { + if !grouped.iter().any(|x| crate::selection::same_item(x, &piece)) { + grouped.push(piece); + } + } + } + let out = grouped; if let Some(mut d) = app.dialog.take() { - d.take_box(out, add); + d.take_box(&app.session, out, add); app.dialog = Some(d); return; } @@ -2304,6 +2403,137 @@ fn view_menu(app: &mut SolveApp, ui: &mut egui::Ui, button: Rect) -> bool { mod tests { use super::*; + fn cylinder_app() -> SolveApp { + let mut s = solvecraft_engine::Session::default(); + s.execute("solid.cylinder", &json!({"radius": 10, "height": 20})).unwrap(); + let mut app = SolveApp::new(s, crate::Services::default()); + app.ui.show_origin = false; + app + } + + #[test] + fn circular_items_measure_as_whole_rims_and_surfaces() { + let mut app = cylinder_app(); + let st = app.session.world_state(); + let b = &st.bodies[0]; + let m = b.mesh(); + let edges: Vec = m + .edges + .iter() + .enumerate() + .filter(|(_, p)| p.len() > 3 && p.iter().all(|q| (q.z - 20.0).abs() < 1e-6)) + .map(|(index, p)| Sel::Edge { body: b.name.clone(), index, point: polyline_mid(p) }) + .collect(); + let result = app.session.measure_items(&edges); + assert_eq!(result["items"].as_array().unwrap().len(), 1); + assert!((result["items"][0]["length_mm"].as_f64().unwrap() - 20.0 * std::f64::consts::PI).abs() < 0.2, "{result}"); + app.session.selection = edges; + let d = crate::dialogs::Dialog::for_command(&app, "inspect.measure").unwrap(); + assert_eq!(d.items().len(), 1); + + let faces = m.edge_faces.iter().zip(&m.seams).find(|(_, seam)| **seam).unwrap().0; + let wall = Sel::Face { body: b.name.clone(), index: faces[0] as usize, point: Vec3::new(10.0, 0.0, 10.0) }; + let wall_pieces = grouped_selection(&app.session, &wall); + let result = app.session.measure_items(&wall_pieces); + assert_eq!(result["items"].as_array().unwrap().len(), 1); + assert!((result["items"][0]["area_mm2"].as_f64().unwrap() - 400.0 * std::f64::consts::PI).abs() < 10.0, "{result}"); + let mut d = crate::dialogs::Dialog::for_command(&app, "inspect.measure").unwrap(); + d.pick(&app.session, wall.clone()); + assert_eq!(d.items().len(), 2, "a rim and a wall are two measurement targets"); + d.pick(&app.session, Sel::Face { body: b.name.clone(), index: faces[1] as usize, point: Vec3::new(-10.0, 0.0, 10.0) }); + assert_eq!(d.items().len(), 1, "the other half toggles the same wall"); + } + + #[test] + fn circular_rim_hover_click_and_dialog_pick_include_both_halves() { + let mut app = cylinder_app(); + let st = app.session.world_state(); + let b = &st.bodies[0]; + let m = b.mesh(); + let top = m.edges.iter().position(|e| e.len() > 3 && e.iter().all(|p| (p.z - 20.0).abs() < 1e-6)).unwrap(); + let rim = m.circular_rim(top); + assert_eq!(rim.len(), 2); + let line_count: usize = rim.iter().map(|&i| m.edges[i].len() - 1).sum(); + for &i in &rim { + let mid = polyline_mid(&m.edges[i]); + app.viewport.hover = Some(Hit::Edge { body: b.name.clone(), index: i, mid }); + assert_eq!(build_highlight(&app).lines.len(), 2 * line_count * crate::gpu::LINE_SIZE); + let item = hit_sel(app.viewport.hover.as_ref().unwrap()).unwrap(); + select(&mut app, Some(item.clone()), false); + assert_eq!(app.session.selection.len(), 2); + // Selecting both pieces must not draw each highlight twice. + assert_eq!(build_highlight(&app).lines.len(), 2 * line_count * crate::gpu::LINE_SIZE); + // Ctrl/Shift clicking either half deselects the entire rim. + select(&mut app, Some(item.clone()), true); + assert!(app.session.selection.is_empty()); + let mut d = crate::dialogs::Dialog::for_command(&app, "solid.chamfer").unwrap(); + if let crate::dialogs::Kind::Fillet { chain, .. } = &mut d.kind { + *chain = false; + } + d.pick(&app.session, item.clone()); + assert_eq!(d.items().len(), 2); + d.pick(&app.session, item); + assert!(d.items().is_empty()); + } + // Screen-space picking works from either side of the rim as the model turns. + for &i in &rim { + let mid = polyline_mid(&m.edges[i]); + app.cam = app.cam.looking_from(Vec3::new(mid.x, mid.y, 20.0)); + app.cam.target = Vec3::new(0.0, 0.0, 10.0); + for perspective in [false, true] { + app.ui.perspective = perspective; + let proj = projection(&app, Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))); + let hits = pick(&app, &proj, proj.to_screen(mid).unwrap()); + let (_, sel) = candidate(&app, &hits).unwrap(); + assert!(matches!(sel, Sel::Edge { .. }), "{hits:?}"); + assert_eq!(grouped_selection(&app.session, &sel).len(), 2); + } + } + } + + #[test] + fn curved_face_hover_selection_and_dialogs_cross_the_seam() { + let mut app = cylinder_app(); + let st = app.session.world_state(); + let b = &st.bodies[0]; + let m = b.mesh(); + let faces = m.edge_faces.iter().zip(&m.seams).find(|(_, seam)| **seam).unwrap().0; + assert_eq!(faces.len(), 2); + let triangle_count = m.tri_face.iter().filter(|f| faces.contains(f)).count(); + for &f in faces { + let index = f as usize; + let t = m.triangles.iter().zip(&m.tri_face).find(|(_, tf)| **tf == f).unwrap().0; + let [a, c, d] = m.tri(t).unwrap(); + let point = (a + c + d) / 3.0; + app.viewport.hover = Some(Hit::Face { body: b.name.clone(), index, point }); + assert_eq!(build_highlight(&app).tris.len(), triangle_count * 3 * crate::gpu::TRI_SIZE); + let item = hit_sel(app.viewport.hover.as_ref().unwrap()).unwrap(); + select(&mut app, Some(item.clone()), false); + assert_eq!(app.session.selection.len(), 2); + assert_eq!(build_highlight(&app).tris.len(), triangle_count * 3 * crate::gpu::TRI_SIZE); + assert_eq!(app.session.selection.first(), Some(&item), "keep the original tool placement point first"); + let mut dialog = crate::dialogs::Dialog::for_command(&app, "solid.chamfer").unwrap(); + assert_eq!(dialog.items().len(), 2); + dialog.pick(&app.session, item.clone()); + assert!(dialog.items().is_empty()); + dialog.pick(&app.session, item); + assert_eq!(dialog.items().len(), 2); + // A point elsewhere on the opposite half still toggles the whole surface off. + let other = faces.iter().find(|&&x| x != f).unwrap(); + select(&mut app, Some(Sel::Face { body: b.name.clone(), index: *other as usize, point: m.positions[0] }), true); + assert!(app.session.selection.is_empty()); + } + // Planar caps and box corners stay separate. + let cap = (0..b.body.face_count()).find(|&i| m.surface_patch(i).len() == 1).unwrap(); + assert_eq!(m.surface_patch(cap), vec![cap]); + app.session.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap(); + let st = app.session.world_state(); + let box_body = st.bodies.last().unwrap(); + for i in 0..box_body.body.face_count() { + assert_eq!(box_body.mesh().surface_patch(i), vec![i]); + } + } + #[test] fn section_outlines_follow_the_cut() { let mut s = solvecraft_engine::Session::default(); From 54a86dc6f573955b1f5bc566d5c0a28874a4354a Mon Sep 17 00:00:00 2001 From: CypherNaugh_0x <9931495+CypherNaught-0x@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:21:47 +0200 Subject: [PATCH 07/15] Deleting a sketch deletes every feature built from it: a pipe whose path sketch was deleted stayed in the timeline as a broken feature ("unknown sketch"), and so did holes drilled at sketch points and patches filling sketch regions. Which sketches a feature uses now comes from one place, FeatureKind::input_sketches, a match over every kind with no wildcard arm, so a new feature kind does not compile until it says which sketches it uses; a test reads the FeatureKind definition and fails when a kind with a sketch field is listed as using none (it catches a sketch field added to an existing kind). timeline.dependents reports the same features --- crates/doc/src/document.rs | 73 +++++++++++++++++++++++++++++++------- crates/doc/src/tests.rs | 50 ++++++++++++++++++++++++++ crates/engine/src/tests.rs | 16 +++++++++ 3 files changed, 127 insertions(+), 12 deletions(-) diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index c7f70e3a..7b94485c 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -939,6 +939,66 @@ fn z_axis() -> Vec3 { } impl FeatureKind { + /// Sketches the feature is built from (profiles, paths, points): deleting one of them + /// deletes the feature. Every kind is listed, so a new one has to say what it uses. + pub fn input_sketches(&self) -> Vec { + use FeatureKind as K; + match self { + K::Extrude { sketch, .. } + | K::Revolve { sketch, .. } + | K::Emboss { sketch, .. } + | K::Rib { sketch, .. } + | K::SheetBase { sketch, .. } + | K::SheetContour { sketch, .. } => vec![*sketch], + K::Sweep { sketch, path_sketch, .. } => vec![*sketch, *path_sketch], + K::Pipe { path_sketch, .. } | K::Pattern { pattern: PatternKind::Path { path_sketch, .. }, .. } => vec![*path_sketch], + K::Loft { sections, .. } => sections.iter().map(|s| s.sketch).collect(), + K::SheetFold { sketch, .. } | K::SplitFace { sketch, .. } | K::Patch { sketch, .. } => sketch.iter().copied().collect(), + K::Hole { points, .. } => points.iter().map(|p| p.sketch).collect(), + K::Sketch { .. } + | K::Pattern { .. } + | K::Fillet { .. } + | K::Chamfer { .. } + | K::Box { .. } + | K::Cylinder { .. } + | K::Sphere { .. } + | K::Torus { .. } + | K::Combine { .. } + | K::Mirror { .. } + | K::Thread { .. } + | K::Shell { .. } + | K::Draft { .. } + | K::ConstructionPlane { .. } + | K::Split { .. } + | K::ConstructionAxis { .. } + | K::ConstructionPoint { .. } + | K::Scale { .. } + | K::OffsetFace { .. } + | K::DeleteFace { .. } + | K::BoundingSolid { .. } + | K::ReplaceFace { .. } + | K::Align { .. } + | K::Coil { .. } + | K::SheetFlange { .. } + | K::SheetHem { .. } + | K::SheetUnfold { .. } + | K::SheetConvert { .. } + | K::Boss { .. } + | K::Lip { .. } + | K::SnapFit { .. } + | K::Rest { .. } + | K::BoundaryFill { .. } + | K::Remove { .. } + | K::Stitch { .. } + | K::Thicken { .. } + | K::SurfaceExtend { .. } + | K::SurfaceTrim { .. } + | K::Move { .. } + | K::Import { .. } + | K::MeshImport { .. } => Vec::new(), + } + } + /// A sheet metal feature (its result depends on the sheet metal rules). pub fn is_plastic(&self) -> bool { matches!(self, FeatureKind::Boss { .. } | FeatureKind::Lip { .. } | FeatureKind::SnapFit { .. } | FeatureKind::Rest { .. }) @@ -1620,18 +1680,7 @@ impl Document { pub fn delete_feature(&mut self, id: u64) -> Result> { let idx = self.feature_index(id).ok_or_else(|| DocError::Unknown(format!("feature {id}")))?; let mut gone = vec![id]; - for f in &self.features { - match &f.kind { - FeatureKind::Extrude { sketch, .. } | FeatureKind::Revolve { sketch, .. } if *sketch == id => gone.push(f.id), - FeatureKind::Sweep { sketch, path_sketch, .. } if *sketch == id || *path_sketch == id => gone.push(f.id), - FeatureKind::Loft { sections, .. } if sections.iter().any(|s| s.sketch == id) => gone.push(f.id), - FeatureKind::Pattern { pattern: PatternKind::Path { path_sketch, .. }, .. } if *path_sketch == id => gone.push(f.id), - FeatureKind::Emboss { sketch, .. } | FeatureKind::Rib { sketch, .. } if *sketch == id => gone.push(f.id), - FeatureKind::SheetBase { sketch, .. } | FeatureKind::SheetContour { sketch, .. } if *sketch == id => gone.push(f.id), - FeatureKind::SheetFold { sketch: Some(s), .. } | FeatureKind::SplitFace { sketch: Some(s), .. } if *s == id => gone.push(f.id), - _ => {} - } - } + gone.extend(self.features.iter().filter(|f| f.kind.input_sketches().contains(&id)).map(|f| f.id)); self.features.retain(|f| !gone.contains(&f.id)); // Configuration columns of deleted features go with them. let keep: Vec = self.configs.columns.iter().map(|c| !matches!(c, crate::config::Column::Suppress(id) if gone.contains(id))).collect(); diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index b74f6da1..5a4b9016 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -295,3 +295,53 @@ fn parameter_cycles_are_found() { assert!(errs["a"].contains("circular reference: a → b → c → a"), "{}", errs["a"]); assert!(vals.contains_key("d") && errs.contains_key("a") && errs.contains_key("e")); } + +/// Features that use a sketch's points or regions go with it (not only profile and path users). +#[test] +fn input_sketches_cover_points_and_patches() { + let hole: FeatureKind = serde_json::from_value(serde_json::json!({ + "type": "hole", "position": [0, 0, 0], "direction": [0, 0, -1], "diameter": "3", + "points": {"sketch": 7, "ids": ["p1"]}, + })) + .unwrap(); + assert_eq!(hole.input_sketches(), vec![7]); + let patch: FeatureKind = serde_json::from_value(serde_json::json!({"type": "patch", "sketch": 9})).unwrap(); + assert_eq!(patch.input_sketches(), vec![9]); + let pipe: FeatureKind = serde_json::from_value(serde_json::json!({"type": "pipe", "path_sketch": 72, "path": ["l1"], "diameter": "4"})).unwrap(); + assert_eq!(pipe.input_sketches(), vec![72]); +} + +/// Guard for `FeatureKind::input_sketches`: a variant with a field that refers to a sketch +/// (a field named `*sketch*` holding an id, sketch points or loft sections) must not sit in the +/// arm that returns no sketches. New variants are caught by the compiler (the match has no +/// wildcard); this catches a sketch field added to an existing variant. +#[test] +fn input_sketches_lists_every_variant_with_a_sketch_field() { + let src = include_str!("document.rs"); + let body = src.split("pub enum FeatureKind {").nth(1).and_then(|s| s.split("\n}\n").next()).unwrap(); + let mut with_sketch = Vec::new(); + let mut variant = ""; + for line in body.lines() { + let t = line.trim(); + if line.starts_with(" ") && !line.starts_with(" ") && t.chars().next().is_some_and(|c| c.is_ascii_uppercase()) { + variant = t.split(|c: char| !c.is_alphanumeric()).next().unwrap_or(""); + continue; + } + let Some((name, ty)) = t.strip_prefix("pub ").unwrap_or(t).split_once(':') else { continue }; + let refers = (name.contains("sketch") && (ty.contains("u64") || ty.contains("SketchPoints"))) + || ty.contains("SketchPoints") + || ty.contains("LoftSection"); + if refers && !line.trim_start().starts_with("//") && !with_sketch.contains(&variant) { + with_sketch.push(variant); + } + } + assert!(with_sketch.len() >= 10, "the FeatureKind parse found too few sketch users: {with_sketch:?}"); + let f = src.split("pub fn input_sketches(&self)").nth(1).unwrap(); + let empty_arm = f.split("=> Vec::new()").next().and_then(|s| s.rsplit("=> ").next()).unwrap(); + for v in with_sketch { + assert!( + !empty_arm.contains(&format!("K::{v} {{")), + "FeatureKind::{v} has a sketch field but input_sketches says it uses no sketch: deleting the sketch would leave it broken" + ); + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 3c4a6714..3a06c2e6 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -839,6 +839,22 @@ fn pipe_scale_offset_bounding_and_materials() { assert!(s.execute("material.assign", &json!({"bodies": ["Cube"], "material": "unobtainium"})).is_err()); } +/// Regression: deleting a pipe's path sketch left the pipe in the timeline ("unknown sketch"). +#[test] +fn deleting_a_path_sketch_deletes_its_pipe() { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Path"})); + run(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 0]], "ids": ["l1"]})); + run(&mut s, "sketch.finish", json!({})); + let pipe = run(&mut s, "solid.pipe", json!({"path_sketch": "Path", "path": ["l1"], "diameter": 4})); + let deps = run(&mut s, "timeline.dependents", json!({"feature": "Path"})); + assert_eq!(deps["deleted_with_it"].as_array().map(Vec::len), Some(1), "{deps} (pipe {pipe})"); + let r = run(&mut s, "timeline.delete", json!({"features": ["Path"]})); + assert_eq!(r["deleted"].as_array().map(Vec::len), Some(2), "{r}"); + assert!(s.doc.features.is_empty(), "{:?}", s.doc.features.iter().map(|f| &f.name).collect::>()); + assert!(s.model.results.iter().all(|r| r.error.is_none())); +} + #[test] fn components_occurrences_and_world_placement() { let mut s = Session::default(); From ab23fc99061254e480bbb7840ff024bd32c908ef Mon Sep 17 00:00:00 2001 From: CypherNaugh_0x <9931495+CypherNaught-0x@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:21:47 +0200 Subject: [PATCH 08/15] A failed MCP batch goes back to where it started however long the undo history is: rollback undid as many steps as the undo history had grown, but the history is capped at 200 steps, so once it was full a failed batch reported undone: 0 and left its sketches behind (an empty Sketch1, two sketches named TapHole). New commands edit.checkpoint and edit.restore_checkpoint (forget: true drops one) remember and restore the design, the active sketch and the undo history; batch takes a checkpoint before its first step, restores it on a failure and forgets it on success. Against an app without checkpoints batch falls back to counting undo steps --- crates/engine/src/cmd/edit.rs | 21 ++++++++++++ crates/engine/src/lib.rs | 58 +++++++++++++++++++++++++++++++ crates/engine/src/tests.rs | 29 ++++++++++++++++ crates/mcp/src/tests.rs | 29 ++++++++++++++++ crates/mcp/src/tools.rs | 64 +++++++++++++++++++++++++++++------ docs/mcp.md | 7 ++-- 6 files changed, 195 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 08eb8695..cdfcd4cc 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -10,6 +10,12 @@ use crate::{EngineError, Result, Sel, Session, Snapshot}; pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("edit.undo", "Undo", undo).icon("undo").key("Ctrl+Z").noundo(), CommandSpec::new("edit.redo", "Redo", redo).icon("redo").key("Ctrl+Y").noundo(), + CommandSpec::new("edit.checkpoint", "Checkpoint", checkpoint) + .noundo() + .params("→ {checkpoint: id}: remember the design, active sketch and undo history (restore with edit.restore_checkpoint)"), + CommandSpec::new("edit.restore_checkpoint", "Restore Checkpoint", restore_checkpoint) + .noundo() + .params("checkpoint: id; forget?: bool (true = drop it without going back). Goes back to it, undo history included"), CommandSpec::new("timeline.compute_all", "Compute All", compute_all).at("SOLID", "MODIFY").icon("compute").noundo(), CommandSpec::new("timeline.delete", "Delete", delete) .at("SOLID", "MODIFY") @@ -60,6 +66,21 @@ fn redo(s: &mut Session, _p: &Value) -> Result { Ok(json!({"redone": label})) } +fn checkpoint(s: &mut Session, _p: &Value) -> Result { + Ok(json!({"checkpoint": s.checkpoint()})) +} + +fn restore_checkpoint(s: &mut Session, p: &Value) -> Result { + let cmd = "edit.restore_checkpoint"; + let id = p.get("checkpoint").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "`checkpoint` must be an id from edit.checkpoint"))?; + if bool_(p, "forget") == Some(true) { + s.drop_checkpoint(id); + return Ok(json!({"checkpoint": id, "restored": false})); + } + s.restore_checkpoint(id)?; + Ok(json!({"checkpoint": id, "restored": true})) +} + fn compute_all(s: &mut Session, _p: &Value) -> Result { s.model = solvecraft_doc::Model::new(); s.refresh(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2ce89495..22a5106f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -170,6 +170,20 @@ pub struct Session { pub analysis: Option, /// What is hidden or shown in the view (`browser.visibility`). pub visibility: Visibility, + /// States to go back to (`edit.checkpoint`, `edit.restore_checkpoint`): how batches of + /// commands are undone however long the undo history is. + pub(crate) checkpoints: Vec, + next_checkpoint: u64, +} + +/// The design, active sketch and undo history at an `edit.checkpoint`. +#[derive(Clone, Debug)] +pub(crate) struct Checkpoint { + pub id: u64, + pub doc: Arc, + pub active_sketch: Option, + pub undo: Vec, + pub redo: Vec, } /// Items hidden or shown one by one (view state, not part of the design and not an undo step). @@ -204,6 +218,8 @@ pub enum SurfaceAnalysis { } const MAX_UNDO: usize = 200; +/// Checkpoints kept (the oldest go first). +const MAX_CHECKPOINTS: usize = 16; const MAX_LOG: usize = 2000; impl Default for Session { @@ -235,6 +251,8 @@ impl Session { clipboard: Vec::new(), section: None, analysis: None, + checkpoints: Vec::new(), + next_checkpoint: 1, visibility: Visibility::default(), } } @@ -356,6 +374,44 @@ impl Session { } } + /// Remember the design, active sketch and undo history; returns the checkpoint's id. + pub fn checkpoint(&mut self) -> u64 { + let id = self.next_checkpoint; + self.next_checkpoint += 1; + self.checkpoints.push(Checkpoint { + id, + doc: self.doc.clone(), + active_sketch: self.active_sketch, + undo: self.undo.clone(), + redo: self.redo.clone(), + }); + if self.checkpoints.len() > MAX_CHECKPOINTS { + self.checkpoints.remove(0); + } + id + } + + /// Go back to checkpoint `id` as if nothing since had happened (the undo history too). It + /// and later checkpoints are used up. + pub fn restore_checkpoint(&mut self, id: u64) -> Result<()> { + let i = self.checkpoints.iter().position(|c| c.id == id).ok_or_else(|| EngineError::Other(format!("no checkpoint {id}")))?; + let Some(c) = self.checkpoints.drain(i..).next() else { return Err(EngineError::Other(format!("no checkpoint {id}"))) }; + self.doc = c.doc; + self.active_sketch = c.active_sketch; + self.undo = c.undo; + self.redo = c.redo; + self.selection.clear(); + self.refresh(); + Ok(()) + } + + /// Forget checkpoint `id` (and later ones) without going back. + pub fn drop_checkpoint(&mut self, id: u64) { + if let Some(i) = self.checkpoints.iter().position(|c| c.id == id) { + self.checkpoints.truncate(i); + } + } + /// A scratch copy of the session (same design and model, no history or log) to try /// commands on without touching this one. pub fn scratch(&self) -> Session { @@ -377,6 +433,8 @@ impl Session { clipboard: self.clipboard.clone(), section: None, analysis: None, + checkpoints: Vec::new(), + next_checkpoint: 1, visibility: Visibility::default(), } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 3a06c2e6..2495c83c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -855,6 +855,35 @@ fn deleting_a_path_sketch_deletes_its_pipe() { assert!(s.model.results.iter().all(|r| r.error.is_none())); } +/// Regression: batches were rolled back by undoing as many steps as the undo history grew, +/// which is none once the history is full, leaving their sketches behind. +#[test] +fn checkpoint_restores_with_a_full_undo_history() { + let mut s = Session::default(); + for i in 0..MAX_UNDO + 5 { + run(&mut s, "parameters.add", json!({"name": format!("p{i}"), "expression": "1 mm"})); + } + assert_eq!(s.undo.len(), MAX_UNDO); + let labels: Vec = s.undo.iter().map(|u| u.label.clone()).collect(); + let doc = s.doc.clone(); + let id = run(&mut s, "edit.checkpoint", json!({}))["checkpoint"].as_u64().unwrap(); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "TapHole"})); + run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 2})); + assert!(s.active_sketch.is_some()); + run(&mut s, "edit.restore_checkpoint", json!({"checkpoint": id})); + assert!(Arc::ptr_eq(&s.doc, &doc) && s.active_sketch.is_none()); + assert!(s.doc.find_feature("TapHole").is_none()); + assert_eq!(s.undo.iter().map(|u| u.label.clone()).collect::>(), labels); + // Used up: a second restore is an error, not a jump somewhere else. + assert!(s.execute("edit.restore_checkpoint", &json!({"checkpoint": id})).is_err()); + // Forgetting one leaves the design alone. + let id = run(&mut s, "edit.checkpoint", json!({}))["checkpoint"].as_u64().unwrap(); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Kept"})); + run(&mut s, "edit.restore_checkpoint", json!({"checkpoint": id, "forget": true})); + assert!(s.doc.find_feature("Kept").is_some()); + assert!(s.execute("edit.restore_checkpoint", &json!({"checkpoint": id})).is_err()); +} + #[test] fn components_occurrences_and_world_placement() { let mut s = Session::default(); diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index f050cd49..64aa12f8 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -213,6 +213,35 @@ fn batch_stops_at_first_error_and_rolls_back() { assert_eq!(tool(&mut s, "measure", json!({}))["body_count"], 2); } +/// Regression: with a full undo history a failed batch reported `undone: 0` and left its +/// sketches behind (an empty "Sketch1", two sketches named "TapHole"). +#[test] +fn batch_rolls_back_with_a_full_undo_history() { + let mut s = server(); + let fill: Vec = + (0..230).map(|i| json!({"command": "parameters.add", "params": {"name": format!("p{i}"), "expression": "1 mm"}})).collect(); + tool(&mut s, "batch", json!({"commands": fill})); + let undo = tool(&mut s, "inspect_design", json!({"measure": false}))["undo"].clone(); + for _ in 0..2 { + let r = raw_tool( + &mut s, + "batch", + json!({"commands": [ + {"command": "sketch.create", "params": {"plane": "XY", "name": "TapHole"}}, + {"command": "sketch.circle.center", "params": {"center": [0, 0], "radius": 2}}, + {"command": "sketch.dimension", "params": {"entities": ["nope"], "value": 3}}, + ]}), + ); + let body: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap(); + assert_eq!(body["failed"]["index"], 2, "{body}"); + assert_eq!(body["undone"], 2, "{body}"); + } + let d = tool(&mut s, "inspect_design", json!({"measure": false, "sketches": false})); + assert_eq!(d["sketches"].as_array().map(Vec::len), Some(0), "{d}"); + assert!(d["active_sketch"].is_null(), "{d}"); + assert_eq!(d["undo"], undo); +} + #[test] fn helper_sketches_can_be_hidden() { let mut s = server(); diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 0d1e993a..938e0de7 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -361,7 +361,56 @@ fn undo_depth(b: &mut dyn Backend) -> Option { b.call("document.inspect", json!({})).ok().and_then(|d| d["undo"].as_u64()) } -/// `batch`: run steps in order, stop at the first failure and (by default) undo what ran. +/// How a failed batch goes back to where it started. +enum Undo { + /// An engine checkpoint: the design, active sketch and undo history exactly as they were. + Checkpoint(u64), + /// An app without checkpoints: undo as many steps as the batch added (wrong once the undo + /// history is full, as its depth no longer grows). + Depth(u64), + None, +} + +impl Undo { + fn begin(b: &mut dyn Backend) -> Undo { + match exec(b, "edit.checkpoint", json!({})).ok().and_then(|v| v["checkpoint"].as_u64()) { + Some(id) => Undo::Checkpoint(id), + None => undo_depth(b).map_or(Undo::None, Undo::Depth), + } + } + + /// Go back; the number of steps undone. + fn rollback(self, b: &mut dyn Backend, steps: usize) -> u64 { + match self { + Undo::Checkpoint(id) => match exec(b, "edit.restore_checkpoint", json!({"checkpoint": id})) { + Ok(_) => steps as u64, + Err(_) => 0, + }, + Undo::Depth(before) => { + let after = undo_depth(b).unwrap_or(before); + let mut undone = 0u64; + for _ in before..after { + if exec(b, "edit.undo", json!({})).is_err() { + break; + } + undone += 1; + } + undone + } + Undo::None => 0, + } + } + + /// The batch went through: forget the checkpoint. + fn finish(self, b: &mut dyn Backend) { + if let Undo::Checkpoint(id) = self { + let _ = exec(b, "edit.restore_checkpoint", json!({"checkpoint": id, "forget": true})); + } + } +} + +/// `batch`: run steps in order, stop at the first failure and (by default) go back to the +/// design as it was before the batch. fn batch(b: &mut dyn Backend, a: &Map) -> Value { let Some(steps) = a.get("commands").and_then(Value::as_array) else { return text_result(&json!("`commands` must be an array of {command, params}"), true); @@ -370,7 +419,7 @@ fn batch(b: &mut dyn Backend, a: &Map) -> Value { return text_result(&json!(format!("batch too long ({} steps, at most {MAX_BATCH})", steps.len())), true); } let rollback = a.get("rollback").and_then(Value::as_bool).unwrap_or(true); - let before = if rollback { undo_depth(b) } else { None }; + let start = if rollback { Undo::begin(b) } else { Undo::None }; let mut results = Vec::with_capacity(steps.len()); for (i, step) in steps.iter().enumerate() { let command = step.get("command").or_else(|| step.get("id")).and_then(Value::as_str); @@ -381,15 +430,7 @@ fn batch(b: &mut dyn Backend, a: &Map) -> Value { match r { Ok(v) => results.push(v), Err(e) => { - let mut undone = 0u64; - if let (Some(before), Some(after)) = (before, rollback.then(|| undo_depth(b)).flatten()) { - for _ in before..after { - if exec(b, "edit.undo", json!({})).is_err() { - break; - } - undone += 1; - } - } + let undone = start.rollback(b, i); return text_result( &json!({ "ok": false, @@ -404,6 +445,7 @@ fn batch(b: &mut dyn Backend, a: &Map) -> Value { } } } + start.finish(b); text_result(&json!({"ok": true, "completed": results.len(), "results": results}), false) } diff --git a/docs/mcp.md b/docs/mcp.md index 3aec985e..63370d71 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -69,8 +69,11 @@ Errors: an unknown tool or arguments that don't match the tool's schema are JSON `-32602` errors; an engine failure (bad command parameters, a feature that fails) is a tool result with `isError: true` and the engine's message, which the model can read and correct. -`batch` rollback undoes as many steps as the batch added to the undo history; batches that -themselves call `edit.undo`, `file.new` or `doc.open` are not rolled back exactly. +`batch` rollback goes back to an engine checkpoint (`edit.checkpoint`) taken before the first +step: the design, the active sketch and the undo history are restored exactly as they were, +however long the history is. Batches that call `file.new` or `doc.open` get the design back, +not its file path. (An older app without checkpoints falls back to undoing as many steps as +the batch added to the undo history.) ## Demo: an enclosure built over MCP From 6f16b317710733d352255a7b40ed506d137ca253 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Sat, 10 Oct 2026 17:28:12 -0400 Subject: [PATCH 09/15] Fix viewport input leaking through dialogs Use egui pointer ownership for viewport navigation and picking, view-cube interaction, and sketch-dimension hover. Cover scrolling, model selection, and cube interaction under floating dialogs, including after they close. Shield constraint glyph and move-gizmo highlights from overlapping UI too. Clear strict Clippy warnings and use the same saved path in the recent-file scenario on Windows and Unix. Fixes #87 --- crates/doc/src/project.rs | 2 +- crates/doc/src/sheet.rs | 2 +- crates/engine/src/cmd/sketch_modify.rs | 2 +- crates/sketch/src/model.rs | 12 +-- crates/sketch/src/text.rs | 2 +- crates/ui-egui/src/dialogs.rs | 2 +- crates/ui-egui/src/dim_view.rs | 2 +- crates/ui-egui/src/gizmo.rs | 2 +- crates/ui-egui/src/sketch_tools.rs | 2 +- crates/ui-egui/src/viewport.rs | 123 ++++++++++++++++++++++++- crates/ui-egui/tests/scenarios.rs | 4 +- xtask/src/layers.rs | 7 +- 12 files changed, 138 insertions(+), 24 deletions(-) diff --git a/crates/doc/src/project.rs b/crates/doc/src/project.rs index 6e42aa42..5378b1b8 100644 --- a/crates/doc/src/project.rs +++ b/crates/doc/src/project.rs @@ -485,7 +485,7 @@ fn tilted_circle(plane: &Plane, pts: &[Vec3]) -> Option> { let (c, n, r, closed) = circle3(pts)?; let sn = plane.normal(); let cosang = n.dot(sn).abs(); - if cosang > 1.0 - 1e-12 || cosang < 1e-9 { + if !(1e-9..=1.0 - 1e-12).contains(&cosang) { return None; } if closed { diff --git a/crates/doc/src/sheet.rs b/crates/doc/src/sheet.rs index cdbd6a91..6bee00c8 100644 --- a/crates/doc/src/sheet.rs +++ b/crates/doc/src/sheet.rs @@ -899,7 +899,7 @@ impl SheetBody { return Err(DocError::Invalid("the hem is shorter than its bend".into())); } // A hem folds back over the face it starts from (toward the bottom unless flipped). - let up = !top == flip; + let up = top != flip; let up = !up; self.flanges.push(SheetFlange { parent: panel, diff --git a/crates/engine/src/cmd/sketch_modify.rs b/crates/engine/src/cmd/sketch_modify.rs index 14d4b739..f79c3697 100644 --- a/crates/engine/src/cmd/sketch_modify.rs +++ b/crates/engine/src/cmd/sketch_modify.rs @@ -520,7 +520,7 @@ fn fillet_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<(usize let u = (pa - pc).normalized().ok_or_else(|| bad(cmd, "degenerate line"))?; let v = (pb - pc).normalized().ok_or_else(|| bad(cmd, "degenerate line"))?; let half = 0.5 * u.cross(v).atan2(u.dot(v)).abs(); - if half < 1e-6 || half > std::f64::consts::FRAC_PI_2 - 1e-9 { + if !(1e-6..=std::f64::consts::FRAC_PI_2 - 1e-9).contains(&half) { return Err(bad(cmd, "the lines are parallel")); } let d = r / half.tan(); diff --git a/crates/sketch/src/model.rs b/crates/sketch/src/model.rs index d86535fb..2b5a0079 100644 --- a/crates/sketch/src/model.rs +++ b/crates/sketch/src/model.rs @@ -698,10 +698,8 @@ impl Sketch { | Symmetric { l, .. } | PointLineDistance { l, .. } | LinearDiameter { l, .. } => need_line(l)?, - ArcLength { c, .. } => { - if !matches!(self.curves.get(c).map(|c| &c.kind), Some(CurveKind::Arc { .. })) { - return Err(SketchError::WrongKind(name(c), "arc length needs an arc".into())); - } + ArcLength { c, .. } if !matches!(self.curves.get(c).map(|c| &c.kind), Some(CurveKind::Arc { .. })) => { + return Err(SketchError::WrongKind(name(c), "arc length needs an arc".into())); } Smooth { a, b } => { let ends = |i: usize| self.curves.get(i).and_then(|c| c.kind.ends()); @@ -730,10 +728,8 @@ impl Sketch { return Err(SketchError::WrongKind(name(a), "tangent needs a circle or arc".into())); } } - Equal { a, b } => { - if is_line(a) != is_line(b) { - return Err(SketchError::WrongKind(name(b), "equal needs two lines or two circles/arcs".into())); - } + Equal { a, b } if is_line(a) != is_line(b) => { + return Err(SketchError::WrongKind(name(b), "equal needs two lines or two circles/arcs".into())); } Coincident { p, q } if p == q => return Err(SketchError::Invalid("a point is always coincident with itself".into())), _ => {} diff --git a/crates/sketch/src/text.rs b/crates/sketch/src/text.rs index f0f378b0..f1c9d439 100644 --- a/crates/sketch/src/text.rs +++ b/crates/sketch/src/text.rs @@ -74,7 +74,7 @@ pub fn text_geometry(text: &str, at: Vec2, height: f64, angle: f64) -> Result MAX_TEXT_CHARS { return Err(SketchError::TooLarge); } - if !(height.is_finite() && height > 1e-6 && height < 1e6) || !angle.is_finite() || !at.is_finite() { + if !(height.is_finite() && height > 1e-6 && height < 1e6 && angle.is_finite() && at.is_finite()) { return Err(SketchError::Invalid("text height must be positive".into())); } let face = ttf_parser::Face::parse(FONT, 0).map_err(|e| SketchError::Invalid(format!("font: {e}")))?; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7f3f83ef..27912c30 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1855,7 +1855,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { Err(e) => d.error = Some(e), } } - if !(keep && !cancel) { + if !keep || cancel { crate::dialogs_motion::closed(app, &mut d, applied); } if keep && !cancel { diff --git a/crates/ui-egui/src/dim_view.rs b/crates/ui-egui/src/dim_view.rs index 66382a4c..105529eb 100644 --- a/crates/ui-egui/src/dim_view.rs +++ b/crates/ui-egui/src/dim_view.rs @@ -187,7 +187,7 @@ pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &P } let tk = Tokens::get(); let is_sel = |id: &str| app.session.selection.iter().any(|s| matches!(s, Sel::SketchConstraint { id: x } if x == id)); - let hover = ui.input(|i| i.pointer.hover_pos()).filter(|p| proj.rect.contains(*p)).and_then(hit); + let hover = ui.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(proj.rect)).and_then(hit); let to = |p: Vec2| proj.to_screen(ss.plane.to_world(p)); let font = FontId::proportional(12.0); let centres = chain_centres(sk); diff --git a/crates/ui-egui/src/gizmo.rs b/crates/ui-egui/src/gizmo.rs index 5f1b1eb1..8c211e6a 100644 --- a/crates/ui-egui/src/gizmo.rs +++ b/crates/ui-egui/src/gizmo.rs @@ -255,7 +255,7 @@ pub fn show(ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, id: egui::I } let state: Option = ui.data(|d| d.get_temp(id)); let pointer = ui.input(|i| i.pointer.hover_pos()); - let hovered = if state.is_none() { pointer.filter(|p| proj.rect.contains(*p)).and_then(|p| hit(&sh, p)) } else { None }; + let hovered = if state.is_none() { pointer.filter(|_| ui.rect_contains_pointer(proj.rect)).and_then(|p| hit(&sh, p)) } else { None }; let active = state.map(|s| s.handle).or(hovered); let hot = |h: Handle| active == Some(h); let col = |h: Handle, i: usize| if hot(h) { tk.accent } else { axis_color(i) }; diff --git a/crates/ui-egui/src/sketch_tools.rs b/crates/ui-egui/src/sketch_tools.rs index 2f87a9fc..39f02e8f 100644 --- a/crates/ui-egui/src/sketch_tools.rs +++ b/crates/ui-egui/src/sketch_tools.rs @@ -1090,7 +1090,7 @@ fn snap_hint(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { /// Per frame, after the viewport is drawn: constraint glyphs and the text-entry box. pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &Proj) { SHIFT.with(|c| c.set(ui.input(|i| i.modifiers.shift))); - glyphs(app, painter, proj, ui.input(|i| i.pointer.hover_pos())); + glyphs(app, painter, proj, ui.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(proj.rect))); snap_hint(app, painter, proj); combs(app, painter, proj); if DEFERRED.with(|d| d.replace(false)) { diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index d436dd90..0808c9dd 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -1371,7 +1371,8 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { let secondary_down = secondary_down && app.viewport.context_menu.is_none(); // The view cube handles its own clicks; the model underneath must not see them. let cube = Rect::from_center_size(pos2(rect.right() - 80.0, rect.top() + 80.0), vec2(150.0, 150.0)); - let inside = hover.is_some_and(|p| rect.contains(p) && !cube.contains(p)); + // Floating windows and menus own input over the model, including wheel navigation. + let inside = resp.contains_pointer() && hover.is_some_and(|p| !cube.contains(p)); app.viewport.mouse = hover.filter(|_| inside); // Ctrl or Alt held: drawing and dragging don't snap. crate::drag_snap::set_suppressed(mods.ctrl || mods.alt || mods.command); @@ -2086,7 +2087,7 @@ fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { let (r, u, b) = cube_basis(&app.cam); let (wr, wu, _) = app.cam.basis(); let painter = ui.painter_at(rect); - let hover = ui.input(|i| i.pointer.hover_pos()); + let hover = ui.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(rect)); let targets = cube_targets(); let shapes = cube_projected(&targets, (r, u, b), c); let hovered = hover.and_then(|h| cube_hit(&shapes, h)); @@ -2534,6 +2535,124 @@ mod tests { } } + #[test] + fn shortcuts_window_wheel_does_not_zoom_the_viewport() { + let ctx = egui::Context::default(); + let mut app = SolveApp::new(solvecraft_engine::Session::default(), crate::Services::default()); + app.viewport.no_pixels = true; + crate::help::run(&mut app, &ctx, "shortcuts"); + let mut time = 0.0; + let mut frame = |app: &mut SolveApp, pointer: Option, wheel: f32| { + time += 1.0 / 60.0; + let mut events = Vec::new(); + if let Some(p) = pointer { + events.push(egui::Event::PointerMoved(p)); + } + if wheel != 0.0 { + events.push(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, wheel), + modifiers: egui::Modifiers::NONE, + phase: egui::TouchPhase::Move, + }); + } + let _ = ctx.run_ui( + egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1100.0, 800.0))), + time: Some(time), + events, + ..Default::default() + }, + |ui| { + egui::CentralPanel::default().show(ui, |ui| show(app, ui)); + crate::prefs::show(app, ui.ctx()); + }, + ); + }; + // Let egui establish the floating window's size and hit-test widgets. + for _ in 0..4 { + frame(&mut app, None, 0.0); + } + let window = ctx.memory(|m| m.area_rect(egui::Id::new("sc_prefs"))).unwrap(); + let list = window.center() + vec2(140.0, 0.0); + let height = app.cam.half_height(); + let target = app.cam.target; + frame(&mut app, Some(list), 0.0); + for n in 0..12 { + frame(&mut app, Some(list), if n == 0 { -120.0 } else { 0.0 }); + assert_eq!(app.cam.half_height(), height, "wheel input over shortcuts must stay in the popup"); + assert_eq!(app.cam.target, target); + } + // The window's non-scrolling sidebar must also shield the model. + frame(&mut app, Some(window.left_center() + vec2(30.0, 0.0)), -120.0); + assert_eq!(app.cam.half_height(), height); + // An exposed part of the viewport still accepts wheel navigation with the popup open. + let exposed = pos2(40.0, 700.0); + assert!(!window.contains(exposed)); + frame(&mut app, Some(exposed), 0.0); + frame(&mut app, Some(exposed), -120.0); + assert_ne!(app.cam.half_height(), height); + // Closing the popup returns its former area to the viewport. + app.prefs_window.open = false; + for _ in 0..12 { + frame(&mut app, Some(list), 0.0); + } + let height = app.cam.half_height(); + frame(&mut app, Some(list), -120.0); + assert_ne!(app.cam.half_height(), height); + } + + #[test] + fn floating_dialog_blocks_viewport_hover_and_clicks() { + let mut h = crate::scenario::Harness::new(); + h.call("engine.execute", json!({"command": "solid.box", "params": {"length": 40, "width": 30, "height": 20}})); + h.call("ui.view", json!({"view": "fit"})); + h.app.ui.pick_bodies = true; + let p = h.app.viewport.rect.unwrap().center(); + h.call("ui.move", json!({"x": p.x, "y": p.y})); + assert!(h.app.viewport.hover.is_some(), "the test point must hover over the model"); + h.call("ui.click", json!({"x": p.x, "y": p.y})); + assert!(!h.app.session.selection.is_empty(), "the uncovered model must be selectable"); + h.app.session.selection.clear(); + + h.call("ui.prefs", json!({"open": "Navigation"})); + let window = h.ctx.memory(|m| m.area_rect(egui::Id::new("sc_prefs"))).unwrap(); + assert!(window.contains(p), "the dialog must cover the model at the test point"); + h.call("ui.move", json!({"x": p.x, "y": p.y})); + assert!(h.app.viewport.hover.is_none(), "the dialog must block model highlights"); + assert!(h.app.viewport.mouse.is_none()); + h.call("ui.click", json!({"x": p.x, "y": p.y})); + assert!(h.app.session.selection.is_empty(), "clicks in the dialog must not select the model"); + + h.call("ui.prefs", json!({"button": "cancel"})); + h.call("ui.move", json!({"x": p.x, "y": p.y})); + assert!(h.app.viewport.hover.is_some(), "closing the dialog must restore model highlights"); + h.call("ui.click", json!({"x": p.x, "y": p.y})); + assert!(!h.app.session.selection.is_empty()); + } + + #[test] + fn floating_dialog_blocks_view_cube_highlights_and_clicks() { + let mut h = crate::scenario::Harness::new(); + h.call("ui.resize", json!({"width": 800, "height": 700})); + let p = crate::scenario::handle_at("cube:top").unwrap(); + h.call("ui.move", json!({"x": p.x, "y": p.y})); + assert_eq!(crate::scenario::count_of("cube_highlights"), Some(1.0)); + h.call("ui.prefs", json!({"open": "Navigation"})); + let window = h.ctx.memory(|m| m.area_rect(egui::Id::new("sc_prefs"))).unwrap(); + assert!(window.contains(p), "the floating dialog must cover the cube target"); + let camera = serde_json::to_value(h.app.cam).unwrap(); + h.call("ui.move", json!({"x": p.x, "y": p.y})); + assert_eq!(crate::scenario::count_of("cube_highlights"), Some(0.0)); + h.call("ui.click", json!({"x": p.x, "y": p.y})); + assert_eq!(serde_json::to_value(h.app.cam).unwrap(), camera, "dialog clicks must not turn the view cube"); + h.call("ui.prefs", json!({"button": "cancel"})); + h.call("ui.move", json!({"x": p.x, "y": p.y})); + assert_eq!(crate::scenario::count_of("cube_highlights"), Some(1.0)); + h.call("ui.click", json!({"x": p.x, "y": p.y})); + assert_ne!(serde_json::to_value(h.app.cam).unwrap(), camera); + } + #[test] fn section_outlines_follow_the_cut() { let mut s = solvecraft_engine::Session::default(); diff --git a/crates/ui-egui/tests/scenarios.rs b/crates/ui-egui/tests/scenarios.rs index f0ef2c06..0574bb20 100644 --- a/crates/ui-egui/tests/scenarios.rs +++ b/crates/ui-egui/tests/scenarios.rs @@ -70,9 +70,10 @@ fn angle_dimension_both_interactive_workflows() { fn home_tab_and_file_recent_open_designs() { use serde_json::json; let mut h = Harness::new(); + let path = h.dir.join("recent.solvecraft").to_string_lossy().into_owned(); h.run(&json!([ {"call":"engine.execute","params":{"command":"solid.box","params":{"length":10,"width":10,"height":10}}}, - {"call":"engine.execute","params":{"command":"file.save_as","params":{"path":"$DIR/recent.solvecraft"}}}, + {"call":"engine.execute","params":{"command":"file.save_as","params":{"path":path}}}, {"click":{"handle":"tab:home"}}, {"expect":{"home":true,"documents":1}}, {"click":{"handle":"tab:design:0"}}, @@ -84,7 +85,6 @@ fn home_tab_and_file_recent_open_designs() { {"widget":"Open Recent"} ])) .unwrap(); - let path = h.dir.join("recent.solvecraft").to_string_lossy().into_owned(); h.run(&json!([ {"widget":path}, {"expect":{"home":false,"bodies":1,"documents":3}} diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 0a65c1e8..29abb446 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -163,11 +163,10 @@ pub fn check(crates: &[Crate]) -> Vec { match classify(&d.name) { // Unregistered deps are reported on their own entry. None => {} - Some(Class::Testkit) => { - if d.kind != DepKind::Dev { - out.push(Violation::TestkitAsNormalDep { krate: c.name.clone() }); - } + Some(Class::Testkit) if d.kind != DepKind::Dev => { + out.push(Violation::TestkitAsNormalDep { krate: c.name.clone() }); } + Some(Class::Testkit) => {} Some(dc) => { let to = dc.layer().unwrap_or(u8::MAX); if to >= layer && !(to == layer && intra_layer_allowed(&c.name, &d.name)) { From f17ff913d6b7cb4a529799b81b34a0e4d144f692 Mon Sep 17 00:00:00 2001 From: CypherNaugh_0x <9931495+CypherNaught-0x@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:16:01 +0200 Subject: [PATCH 10/15] A panic while healing a boolean result no longer ends the boolean: a shallow, wide loft cut from a top face (556.68 x 99 down 4 mm to 80 x 80 in a 900 x 480 x 160 slab) failed with "called Option::unwrap() on a None value" heal_keep evaluated the result's intersection curves outside any guard. truck panics where such a curve no longer evaluates, and the panic escaped boolean_whole, so the retries and fallbacks that would have succeeded never ran. heal_keep now runs under guard and returns the solid unhealed when healing panics (as its doc already promised for failures), and the boolean builds its result body under guard too, so a result that cannot be built counts as a failed attempt. Tests: shallow_loft_cut_from_the_top_face replays examples/regressions/shallow_loft_cut_from_top_face.json and checks the exact prismatoid volume (fails on main with the panic). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/boolean_regression_tests.rs | 34 +++++++++++++++++++ crates/engine/src/lib.rs | 2 ++ crates/kernel/src/heal.rs | 10 ++++-- crates/kernel/src/ops.rs | 2 +- .../shallow_loft_cut_from_top_face.json | 16 +++++++++ 5 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 crates/engine/src/boolean_regression_tests.rs create mode 100644 examples/regressions/shallow_loft_cut_from_top_face.json diff --git a/crates/engine/src/boolean_regression_tests.rs b/crates/engine/src/boolean_regression_tests.rs new file mode 100644 index 00000000..4db35a63 --- /dev/null +++ b/crates/engine/src/boolean_regression_tests.rs @@ -0,0 +1,34 @@ +//! Booleans that failed, panicked or hung, replayed from the scripts in +//! `examples/regressions/` (each script's description says what went wrong). + +use serde_json::Value; + +use crate::Session; + +fn replay(script: &str) -> Session { + let mut s = Session::default(); + let script: Value = serde_json::from_str(script).unwrap(); + s.run_script(&script).unwrap(); + s +} + +fn volume(s: &Session, body: &str) -> f64 { + s.model.state().body(body).map(|b| solvecraft_kernel::measure(&b.body).unwrap().volume).unwrap_or(0.0) +} + +/// Slab less a 557.68 × 100 × 120 pocket and the loft below its floor (556.68 × 99 down 4 mm to +/// 80 × 80; prismatoid: h/6 · (A_top + A_bottom + 4 A_mid)). +fn slab_pocket_loft() -> (f64, f64) { + let slab = 900.0 * 480.0 * 160.0; + let loft = 4.0 / 6.0 * (556.68 * 99.0 + 80.0 * 80.0 + 4.0 * (556.68 + 80.0) / 2.0 * (99.0 + 80.0) / 2.0); + (slab - 557.68 * 100.0 * 120.0, loft) +} + +#[test] +fn shallow_loft_cut_from_the_top_face() { + let s = replay(include_str!("../../../examples/regressions/shallow_loft_cut_from_top_face.json")); + let (_, loft) = slab_pocket_loft(); + let want = 900.0 * 480.0 * 160.0 - loft; + let got = volume(&s, "Body1"); + assert!((got - want).abs() < 1e-9 * want, "{got} vs {want}"); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 22a5106f..beb357bb 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -503,6 +503,8 @@ impl Session { } } +#[cfg(test)] +mod boolean_regression_tests; #[cfg(test)] mod construct_tests; #[cfg(test)] diff --git a/crates/kernel/src/heal.rs b/crates/kernel/src/heal.rs index fcd17878..b86c5858 100644 --- a/crates/kernel/src/heal.rs +++ b/crates/kernel/src/heal.rs @@ -404,6 +404,12 @@ pub fn heal(solid: Solid, size: f64) -> Solid { /// [`heal`], keeping faces apart along lines they were split along on purpose. pub(crate) fn heal_keep(solid: Solid, size: f64, keep: &[Vec]) -> Solid { + // Edges a boolean made are intersection curves, which panic where they no longer + // evaluate; the solid then stays as it is. + crate::guard("heal", || Ok(heal_shells(&solid, size, keep))).ok().flatten().unwrap_or(solid) +} + +fn heal_shells(solid: &Solid, size: f64, keep: &[Vec]) -> Option { let tol = (size * 1e-7).max(1e-9); let mut shells = Vec::new(); let mut changed = false; @@ -428,9 +434,9 @@ pub(crate) fn heal_keep(solid: Solid, size: f64, keep: &[Vec]) -> Solid { shells.push(cur); } if !changed { - return solid; + return None; } - Solid::try_new(shells).unwrap_or(solid) + Solid::try_new(shells).ok() } #[cfg(test)] diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs index b812cbb7..1b9f9afd 100644 --- a/crates/kernel/src/ops.rs +++ b/crates/kernel/src/ops.rs @@ -296,7 +296,7 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result> { empty_votes += 1; last = "empty result".into(); } - Ok(s) => match Body::new(crate::heal::heal_keep(s, size, &keep)) { + Ok(s) => match guard("boolean result", || Body::new(crate::heal::heal_keep(s, size, &keep))) { Ok(body) => { let (ok, v) = check(&body); if ok { diff --git a/examples/regressions/shallow_loft_cut_from_top_face.json b/examples/regressions/shallow_loft_cut_from_top_face.json new file mode 100644 index 00000000..a898231b --- /dev/null +++ b/examples/regressions/shallow_loft_cut_from_top_face.json @@ -0,0 +1,16 @@ +{ + "description": "A shallow, wide loft cut starting in a body's top face: slab 900 x 480 x 160, loft cut from a 556.68 x 99 rectangle on the top face down 4 mm to an 80 x 80 square (sides about 0.9 degrees from horizontal). Healing an intermediate boolean result panicked outside the boolean's guard and stopped every later attempt. Result: the slab less the loft, 69 003 015.31 mm³.", + "commands": [ + {"command": "sketch.create", "params": {"plane": "XY", "name": "Base"}}, + {"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [900, 480]}}, + {"command": "sketch.finish", "params": {}}, + {"command": "solid.extrude", "params": {"distance": 160, "name": "Slab"}}, + {"command": "sketch.create", "params": {"plane": "XY", "offset": 160, "name": "LoftTop"}}, + {"command": "sketch.rectangle.two_point", "params": {"p0": [171.66, 329.34], "p1": [728.34, 428.34]}}, + {"command": "sketch.finish", "params": {}}, + {"command": "sketch.create", "params": {"plane": "XY", "offset": 156, "name": "LoftBottom"}}, + {"command": "sketch.rectangle.two_point", "params": {"p0": [410, 338.84], "p1": [490, 418.84]}}, + {"command": "sketch.finish", "params": {}}, + {"command": "solid.loft", "params": {"sections": [{"sketch": "LoftTop"}, {"sketch": "LoftBottom"}], "operation": "cut", "name": "LoftCut"}} + ] +} From f3a689f284f64cf79b1bc0b8da99c49f1058018c Mon Sep 17 00:00:00 2001 From: CypherNaugh_0x <9931495+CypherNaught-0x@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:17:02 +0200 Subject: [PATCH 11/15] A thin part trimmed by a long one is no longer refused as an implausible result: a 437 mm bar with a 3 mm concave arc in its profile, cut by a 920 mm prism with a top tilted 21 degrees, failed with "implausible result volume 3870" (it was 3694 before the cut) The boolean checks its result by meshing both operands and the result, then sampling membership densely where the operands' boxes overlap. The mesh step came from the larger body's size (0.46 mm here), which facets the bar's 3 mm arc so coarsely that two meshes of the same arc disagree over a few percent of the thin overlap box, and a correct cut failed the 0.4 % mismatch limit. The step now also follows the smallest side of the overlap box (2e-3 of it), never finer than 1e-5 of the size, so it only changes where the bodies meet in a region much thinner than they are. truck's first result for the cut is then accepted, and it matches a split of the bar by the tilted plane. Tests: oblique_cut_through_a_small_arc_matches_a_split replays examples/regressions/oblique_cut_of_cylinder.json and compares with solid.split_body by the same plane (fails on main). The full suite runs no slower. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/boolean_regression_tests.rs | 21 ++++++++++++++++++- crates/kernel/src/ops.rs | 19 ++++++++++++++++- .../regressions/oblique_cut_of_cylinder.json | 15 +++++++++++++ 3 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 examples/regressions/oblique_cut_of_cylinder.json diff --git a/crates/engine/src/boolean_regression_tests.rs b/crates/engine/src/boolean_regression_tests.rs index 4db35a63..54f0ee80 100644 --- a/crates/engine/src/boolean_regression_tests.rs +++ b/crates/engine/src/boolean_regression_tests.rs @@ -1,7 +1,7 @@ //! Booleans that failed, panicked or hung, replayed from the scripts in //! `examples/regressions/` (each script's description says what went wrong). -use serde_json::Value; +use serde_json::{Value, json}; use crate::Session; @@ -32,3 +32,22 @@ fn shallow_loft_cut_from_the_top_face() { let got = volume(&s, "Body1"); assert!((got - want).abs() < 1e-9 * want, "{got} vs {want}"); } + +#[test] +fn oblique_cut_through_a_small_arc_matches_a_split() { + let scripts = [include_str!("../../../examples/regressions/oblique_cut_of_cylinder.json")]; + // The same bar split by the prism's tilted top: the piece above it is the cut's result. + let mut split = Session::default(); + let script: Value = serde_json::from_str(scripts[0]).unwrap(); + let steps = script.get("commands").and_then(Value::as_array).unwrap(); + split.run_script(&Value::Array(steps[..5].to_vec())).unwrap(); + let whole = volume(&split, "Tool"); + split.execute("solid.split_body", &json!({"body": "Tool", "plane": {"origin": [0, -472, 853.91], "normal": [0, 15.44, 40]}})).unwrap(); + let above = volume(&split, "Tool"); + assert!(above < whole - 10.0, "{above} of {whole}"); + for script in scripts { + let s = replay(script); + let got = volume(&s, "Tool"); + assert!((got - above).abs() < 1e-6 * above, "{got} vs {above}"); + } +} diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs index 1b9f9afd..14de038f 100644 --- a/crates/kernel/src/ops.rs +++ b/crates/kernel/src/ops.rs @@ -189,11 +189,28 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result> { r } +/// Smallest side of the box where the bodies' vertex boxes overlap (infinite when they don't). +fn meeting_size(a: &Body, b: &Body) -> f64 { + let bbox = |x: &Body| { + let mut bb = solvecraft_geom::Aabb3::EMPTY; + for v in x.solid.vertex_iter() { + bb.add(from_p3(v.point())); + } + bb + }; + let (ba, bb) = (bbox(a), bbox(b)); + let s = solvecraft_geom::Aabb3 { min: ba.min.max(bb.min), max: ba.max.min(bb.max) }.size(); + if s.x > 0.0 && s.y > 0.0 && s.z > 0.0 { s.x.min(s.y).min(s.z) } else { f64::INFINITY } +} + fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result> { a.require_brep("a boolean")?; b.require_brep("a boolean")?; let size = a.size().max(b.size()); - let tol_m = size * 5e-4; + // The checks mesh both bodies; where they meet in a region much thinner than they are (a + // thin part trimmed by a long one), a step from their size would facet the small faces + // there coarsely enough to fail the membership test on a correct result. + let tol_m = (size * 5e-4).min(meeting_size(a, b) * 2e-3).max(size * 1e-5); let meshes = (a.tessellate(tol_m).ok(), b.tessellate(tol_m).ok()); let (va, vb) = match &meshes { (Some(ma), Some(mb)) => (ma.measure().volume, mb.measure().volume), diff --git a/examples/regressions/oblique_cut_of_cylinder.json b/examples/regressions/oblique_cut_of_cylinder.json new file mode 100644 index 00000000..3fd277ef --- /dev/null +++ b/examples/regressions/oblique_cut_of_cylinder.json @@ -0,0 +1,15 @@ +{ + "description": "A thin 437 mm bar whose profile has a 3 mm concave arc, cut by a long prism whose top is tilted 21 degrees to the bar (an elliptical cut through the arc's cylinder). The check on the result meshed both bodies at a step from the 920 mm prism, which faceted the arc so coarsely that a correct cut failed the membership test. Result: the bar with its end trimmed by the tilted plane, as a split by that plane gives.", + "commands": [ + {"command": "sketch.create", "params": {"plane": "XZ", "offset": -462, "name": "Profile"}}, + {"command": "sketch.line", "params": {"points": [[147.4407, -849.95], [147.4407, -851.0], [151.3952, -851.0], [151.3952, -847.4709], [150.3952, -847.4709]]}}, + {"command": "sketch.arc.center_point", "params": {"center": [147.4407, -846.95], "start": "l1.start", "end": "l4.end"}}, + {"command": "sketch.finish", "params": {}}, + {"command": "solid.extrude", "params": {"sketch": "Profile", "profiles": "all", "direction": "positive", "start_offset": -1, "distance": 437.52, "operation": "new", "body_name": "Tool"}}, + {"command": "sketch.create", "params": {"plane": "YZ", "name": "Clip"}}, + {"command": "sketch.line", "params": {"points": [[-472, 853.91], [-432, 838.47], [-432, 820], [-472, 820]], "closed": true}}, + {"command": "sketch.finish", "params": {}}, + {"command": "solid.extrude", "params": {"sketch": "Clip", "profiles": "all", "start_offset": -10, "distance": 920, "operation": "new", "body_name": "ClipBody"}}, + {"command": "solid.combine", "params": {"target": "Tool", "tools": ["ClipBody"], "operation": "cut", "keep_tools": true}} + ] +} From 5249ae84ef8abb6b5c9744549cbe6953d365aa3c Mon Sep 17 00:00:00 2001 From: CypherNaugh_0x <9931495+CypherNaught-0x@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:31:46 +0200 Subject: [PATCH 12/15] Moving a body that a boolean made no longer hangs: a boolean between a cylinder and a tilted plane (an elliptical edge) froze the app at 100 % CPU, because its retries move both bodies and the result back Moving a body transforms its intersection-curve edges, and truck-geometry 0.5.0 scales each curve's tolerance by the operator norm of the matrix. matext4cgmath 0.1.0 computes it from eigenvalues through a quartic whose Newton refinement loops while the residual is above a tolerance and never gives up. For a translation matrix all four eigenvalues are 1, and near that quadruple root the iteration can cycle forever (about one translation in eight of the boolean retries' shifts, by a scan of sizes 0.1-2000 mm). matext4cgmath is vendored with both Newton loops stopped after 100 steps (vendor/README.md, ATTRIBUTION.md). truck master no longer computes the norm in transform_by, so the vendored copy can go once a truck release after 0.5.0 is adopted. Tests: moving_a_body_with_an_intersection_curve_edge_ends (kernel) cuts a cylinder with a tilted box and moves the result by one of the shifts that hung (hangs on main). examples/regressions/oblique_cut_of_cylinder_hang.json is the variant of the oblique cut that hung the app; it hangs on main, and since the previous commit it passes without reaching the shifted retries, so it is replayed by the engine test as a scenario only. Co-Authored-By: Claude Opus 5.5 --- ATTRIBUTION.md | 1 + Cargo.lock | 2 - Cargo.toml | 1 + crates/engine/src/boolean_regression_tests.rs | 5 +- crates/kernel/src/tests.rs | 16 ++ .../oblique_cut_of_cylinder_hang.json | 15 ++ vendor/README.md | 12 + vendor/matext4cgmath/Cargo.toml | 40 +++ vendor/matext4cgmath/LICENSE | 201 ++++++++++++++ vendor/matext4cgmath/README.md | 5 + vendor/matext4cgmath/src/eigens.rs | 153 +++++++++++ vendor/matext4cgmath/src/exp_decomp.rs | 106 ++++++++ vendor/matext4cgmath/src/lib.rs | 120 +++++++++ vendor/matext4cgmath/src/solver.rs | 246 ++++++++++++++++++ 14 files changed, 920 insertions(+), 3 deletions(-) create mode 100644 examples/regressions/oblique_cut_of_cylinder_hang.json create mode 100644 vendor/matext4cgmath/Cargo.toml create mode 100644 vendor/matext4cgmath/LICENSE create mode 100644 vendor/matext4cgmath/README.md create mode 100644 vendor/matext4cgmath/src/eigens.rs create mode 100644 vendor/matext4cgmath/src/exp_decomp.rs create mode 100644 vendor/matext4cgmath/src/lib.rs create mode 100644 vendor/matext4cgmath/src/solver.rs diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 4880cd91..92321a60 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -49,3 +49,4 @@ Generated-in-code assets are original and have no file to list: | vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: parameter searches kept inside the surface domain (vendor/README.md) | | vendor/truck-stepio | truck-stepio 0.3.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | STEP writer: intersection-curve surfaces written at unique ids (vendor/README.md) | | vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; trace macro (vendor/README.md) | +| vendor/matext4cgmath | matext4cgmath 0.1.0, https://github.com/ricosjp/matext4cgmath (RICOS Co. Ltd.) | Apache-2.0 | root refinement stops after 100 Newton steps (vendor/README.md) | diff --git a/Cargo.lock b/Cargo.lock index 699ac874..3289c02f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1753,8 +1753,6 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "matext4cgmath" version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6541e181de37f70f0aceb25441823a3d0efa9cc1d23f475a0d3926678949178" dependencies = [ "cgmath", "katexit", diff --git a/Cargo.toml b/Cargo.toml index 693cb1ce..f6190e3c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -98,3 +98,4 @@ debug = 0 truck-shapeops = { path = "vendor/truck-shapeops" } truck-stepio = { path = "vendor/truck-stepio" } truck-meshalgo = { path = "vendor/truck-meshalgo" } +matext4cgmath = { path = "vendor/matext4cgmath" } diff --git a/crates/engine/src/boolean_regression_tests.rs b/crates/engine/src/boolean_regression_tests.rs index 54f0ee80..8d8492ca 100644 --- a/crates/engine/src/boolean_regression_tests.rs +++ b/crates/engine/src/boolean_regression_tests.rs @@ -35,7 +35,10 @@ fn shallow_loft_cut_from_the_top_face() { #[test] fn oblique_cut_through_a_small_arc_matches_a_split() { - let scripts = [include_str!("../../../examples/regressions/oblique_cut_of_cylinder.json")]; + let scripts = [ + include_str!("../../../examples/regressions/oblique_cut_of_cylinder.json"), + include_str!("../../../examples/regressions/oblique_cut_of_cylinder_hang.json"), + ]; // The same bar split by the prism's tilted top: the piece above it is the cut's result. let mut split = Session::default(); let script: Value = serde_json::from_str(scripts[0]).unwrap(); diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs index 869a2639..2dbd1c58 100644 --- a/crates/kernel/src/tests.rs +++ b/crates/kernel/src/tests.rs @@ -258,6 +258,22 @@ fn loft_and_sweep() { assert!(rel(measure(&t).unwrap().volume, expect) < 1e-3, "{} vs {expect}", measure(&t).unwrap().volume); } +#[test] +fn moving_a_body_with_an_intersection_curve_edge_ends() { + // A cylinder cut by a tilted box keeps an elliptical intersection curve as an edge. Moving + // such a body scales the curve's tolerance by the matrix norm, whose root refinement cycled + // forever for about one translation in eight (this one included): the app hung. + let cyl = cylinder(Vec3::ZERO, Vec3::Z, 5.0, 40.0).unwrap(); + let block = box_solid(Vec3::new(-20.0, -20.0, 20.0), Vec3::new(20.0, 20.0, 60.0)).unwrap(); + let block = crate::ops::transform(&block, Vec3::ZERO, Vec3::new(0.0, 0.0, 20.0), Vec3::X, 0.4).unwrap(); + let cut = boolean(&cyl, &block, BoolOp::Cut).unwrap().unwrap(); + assert!(cut.solid.edge_iter().any(|e| matches!(e.curve(), truck_modeling::Curve::IntersectionCurve(_)))); + let t = Vec3::new(0.6911, -0.3337, -0.6127) * 2.334; + let moved = crate::ops::transform(&cut, t, Vec3::ZERO, Vec3::Z, 0.0).unwrap(); + let (v0, v1) = (measure(&cut).unwrap().volume, measure(&moved).unwrap().volume); + assert!(rel(v0, v1) < 1e-6, "{v0} vs {v1}"); +} + #[test] fn planar_booleans_with_coincident_faces() { // L-bracket: a wall flush with three sides of its base. diff --git a/examples/regressions/oblique_cut_of_cylinder_hang.json b/examples/regressions/oblique_cut_of_cylinder_hang.json new file mode 100644 index 00000000..76aea7db --- /dev/null +++ b/examples/regressions/oblique_cut_of_cylinder_hang.json @@ -0,0 +1,15 @@ +{ + "description": "The oblique cut of oblique_cut_of_cylinder.json with the prism running past the bar's end. Moving a boolean result back after a shifted retry computed a matrix norm whose root refinement never ended: the app hung at full CPU. Result: the same trimmed bar.", + "commands": [ + {"command": "sketch.create", "params": {"plane": "XZ", "offset": -462, "name": "Profile"}}, + {"command": "sketch.line", "params": {"points": [[147.4407, -849.95], [147.4407, -851.0], [151.3952, -851.0], [151.3952, -847.4709], [150.3952, -847.4709]]}}, + {"command": "sketch.arc.center_point", "params": {"center": [147.4407, -846.95], "start": "l1.start", "end": "l4.end"}}, + {"command": "sketch.finish", "params": {}}, + {"command": "solid.extrude", "params": {"sketch": "Profile", "profiles": "all", "direction": "positive", "start_offset": -1, "distance": 437.52, "operation": "new", "body_name": "Tool"}}, + {"command": "sketch.create", "params": {"plane": "YZ", "name": "Clip"}}, + {"command": "sketch.line", "params": {"points": [[-480, 856.9979999999999], [-432, 838.47], [-432, 820], [-480, 820]], "closed": true}}, + {"command": "sketch.finish", "params": {}}, + {"command": "solid.extrude", "params": {"sketch": "Clip", "profiles": "all", "start_offset": -10, "distance": 920, "operation": "new", "body_name": "ClipBody"}}, + {"command": "solid.combine", "params": {"target": "Tool", "tools": ["ClipBody"], "operation": "cut", "keep_tools": true}} + ] +} diff --git a/vendor/README.md b/vendor/README.md index 229a7e85..aa10eece 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -49,3 +49,15 @@ Changes: direction (long triangles from a trim loop to the grid's far ends cut through cylinders). - `lib.rs`: compiler warnings no longer fail the build. Example, test and bench targets are dropped (their sources are not vendored). + +## matext4cgmath 0.1.0 (Apache-2.0, © RICOS Co. Ltd., https://github.com/ricosjp/matext4cgmath) + +truck-geometry uses it for a matrix's operator norm (an intersection curve scales its tolerance +by it whenever it is moved). + +Changes: +- `solver.rs`: the Newton refinement of cubic and quartic roots stops after 100 steps. Near a + double root it could cycle forever: moving a body whose edges a boolean made (the retries + move both bodies and the result back) hung the app. +- `lib.rs`: compiler warnings no longer fail the build. Test targets are dropped (their sources + are not vendored). diff --git a/vendor/matext4cgmath/Cargo.toml b/vendor/matext4cgmath/Cargo.toml new file mode 100644 index 00000000..ff2370c9 --- /dev/null +++ b/vendor/matext4cgmath/Cargo.toml @@ -0,0 +1,40 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2021" +name = "matext4cgmath" +version = "0.1.0" +authors = ["Yoshinori Tanimura "] +description = "Unofficial third-party cgmath extensions for calculate eigenvalues, operator norms and Iwasawa decomposition." +homepage = "https://github.com/ricosjp/matext4cgmath" +keywords = [ + "cgmath", + "graphics", + "linear-algebra", +] +categories = ["graphics"] +license = "Apache-2.0" +repository = "https://github.com/ricosjp/matext4cgmath" +resolver = "2" +autotests = false +autoexamples = false +autobenches = false + +[dependencies.cgmath] +version = "0.18.0" + +[dependencies.katexit] +version = "0.1.2" + +[dependencies.num-complex] +version = "0.4.0" + diff --git a/vendor/matext4cgmath/LICENSE b/vendor/matext4cgmath/LICENSE new file mode 100644 index 00000000..1ad4a8f7 --- /dev/null +++ b/vendor/matext4cgmath/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2022 Research Institute for Computational Science Co. Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/vendor/matext4cgmath/README.md b/vendor/matext4cgmath/README.md new file mode 100644 index 00000000..c2ce89d7 --- /dev/null +++ b/vendor/matext4cgmath/README.md @@ -0,0 +1,5 @@ +# matext4cgmath + +[![Crates.io](https://img.shields.io/crates/v/matext4cgmath.svg)](https://crates.io/crates/matext4cgmath) [![Docs.rs](https://docs.rs/matext4cgmath/badge.svg)](https://docs.rs/matext4cgmath) + +Unofficial third-party cgmath extensions for calculate eigenvalues, operator norms and Iwasawa decomposition. diff --git a/vendor/matext4cgmath/src/eigens.rs b/vendor/matext4cgmath/src/eigens.rs new file mode 100644 index 00000000..5f7db7e9 --- /dev/null +++ b/vendor/matext4cgmath/src/eigens.rs @@ -0,0 +1,153 @@ +use crate::*; + +impl EigenValues for Matrix2 { + type EigenValues = [Complex; 2]; + fn eigenvalues(self) -> [Complex; 2] { + solver::solve_quadratic(-self.trace(), self.determinant()) + } +} + +impl EigenValues for Matrix3 { + type EigenValues = [Complex; 3]; + fn eigenvalues(self) -> [Complex; 3] { + let poly2 = self[0][0] * self[1][1] + self[0][0] * self[2][2] + self[1][1] * self[2][2] + - self[0][1] * self[1][0] + - self[1][2] * self[2][1] + - self[0][2] * self[2][0]; + solver::solve_cubic(-self.trace(), poly2, -self.determinant()) + } +} + +impl EigenValues for Matrix4 { + type EigenValues = [Complex; 4]; + fn eigenvalues(self) -> [Complex; 4] { + let poly2 = self[0][0] * self[1][1] + + self[0][0] * self[2][2] + + self[0][0] * self[3][3] + + self[1][1] * self[2][2] + + self[1][1] * self[3][3] + + self[2][2] * self[3][3] + - self[0][1] * self[1][0] + - self[0][2] * self[2][0] + - self[0][3] * self[3][0] + - self[1][2] * self[2][1] + - self[1][3] * self[3][1] + - self[2][3] * self[3][2]; + let poly3 = (self[0][0] * self[1][1] + self[0][0] * self[2][2] + self[1][1] * self[2][2] + - self[0][1] * self[1][0] + - self[0][2] * self[2][0] + - self[1][2] * self[2][1]) + * self[3][3] + + (self[2][0] * self[3][2] - self[2][2] * self[3][0] + self[1][0] * self[3][1] + - self[1][1] * self[3][0]) + * self[0][3] + + (self[2][1] * self[3][2] - self[2][2] * self[3][1] + self[3][0] * self[0][1] + - self[3][1] * self[0][0]) + * self[1][3] + + (self[3][1] * self[1][2] - self[3][2] * self[1][1] + self[3][0] * self[0][2] + - self[3][2] * self[0][0]) + * self[2][3] + + Matrix3::from_cols(self[0].truncate(), self[1].truncate(), self[2].truncate()) + .determinant(); + solver::solve_quartic(-self.trace(), poly2, -poly3, self.determinant()) + } +} + +impl OperatorNorm for Matrix2 { + #[inline] + fn norm_l1(self) -> F { + F::max( + F::abs(self[0][0]) + F::abs(self[0][1]), + F::abs(self[1][0]) + F::abs(self[1][1]), + ) + } + #[inline] + fn norm_linf(self) -> F { + F::max( + F::abs(self[0][0]) + F::abs(self[1][0]), + F::abs(self[0][1]) + F::abs(self[1][1]), + ) + } + #[inline] + fn norm_l2(self) -> F { + let eigens = (self.transpose() * self).eigenvalues(); + F::sqrt(F::max(eigens[0].re, eigens[1].re)) + } +} + +impl OperatorNorm for Matrix3 { + #[inline] + fn norm_l1(self) -> F { + F::max( + F::max( + F::abs(self[0][0]) + F::abs(self[0][1]) + F::abs(self[0][2]), + F::abs(self[1][0]) + F::abs(self[1][1]) + F::abs(self[1][2]), + ), + F::abs(self[2][0]) + F::abs(self[2][1]) + F::abs(self[2][2]), + ) + } + #[inline] + fn norm_linf(self) -> F { + F::max( + F::max( + F::abs(self[0][0]) + F::abs(self[1][0]) + F::abs(self[2][0]), + F::abs(self[0][1]) + F::abs(self[1][1]) + F::abs(self[2][1]), + ), + F::abs(self[0][2]) + F::abs(self[1][2]) + F::abs(self[2][2]), + ) + } + #[inline] + fn norm_l2(self) -> F { + let eigens = (self.transpose() * self).eigenvalues(); + F::sqrt(F::max(F::max(eigens[0].re, eigens[1].re), eigens[2].re)) + } +} + +impl OperatorNorm for Matrix4 { + #[inline] + fn norm_l1(self) -> F { + F::max( + F::max( + F::max( + F::abs(self[0][0]) + + F::abs(self[0][1]) + + F::abs(self[0][2]) + + F::abs(self[0][3]), + F::abs(self[1][0]) + + F::abs(self[1][1]) + + F::abs(self[1][2]) + + F::abs(self[1][3]), + ), + F::abs(self[2][0]) + F::abs(self[2][1]) + F::abs(self[2][2]) + F::abs(self[2][3]), + ), + F::abs(self[3][0]) + F::abs(self[3][1]) + F::abs(self[3][2]) + F::abs(self[3][3]), + ) + } + #[inline] + fn norm_linf(self) -> F { + F::max( + F::max( + F::max( + F::abs(self[0][0]) + + F::abs(self[1][0]) + + F::abs(self[2][0]) + + F::abs(self[3][0]), + F::abs(self[0][1]) + + F::abs(self[1][1]) + + F::abs(self[2][1]) + + F::abs(self[3][1]), + ), + F::abs(self[0][2]) + F::abs(self[1][2]) + F::abs(self[2][2]) + F::abs(self[3][2]), + ), + F::abs(self[0][3]) + F::abs(self[1][3]) + F::abs(self[2][3]) + F::abs(self[3][3]), + ) + } + #[inline] + fn norm_l2(self) -> F { + let eigens = (self.transpose() * self).eigenvalues(); + F::sqrt(F::max( + F::max(F::max(eigens[0].re, eigens[1].re), eigens[2].re), + eigens[3].re, + )) + } +} diff --git a/vendor/matext4cgmath/src/exp_decomp.rs b/vendor/matext4cgmath/src/exp_decomp.rs new file mode 100644 index 00000000..20a90f21 --- /dev/null +++ b/vendor/matext4cgmath/src/exp_decomp.rs @@ -0,0 +1,106 @@ +use crate::*; + +impl Exponential for Matrix2 {} +impl Exponential for Matrix3 {} +impl Exponential for Matrix4 {} + +impl Decomposition for Matrix2 { + fn iwasawa_decomposition(self) -> Option<(Self, Self, Self)> { + let v0 = self[0]; + let mag0 = dot(v0, v0); + if mag0 <= F::zero() { + return None; + } + let n0 = dot(self[1], v0) / mag0; + let v1 = self[1] - v0 * n0; + let mag1 = dot(v1, v1); + if mag1 <= F::zero() { + return None; + } + let (a0, a1) = (F::sqrt(mag0), F::sqrt(mag1)); + Some(( + Matrix2::from_cols(v0 / a0, v1 / a1), + Matrix2::from_diagonal(Vector2::new(a0, a1)), + Matrix2::new(F::one(), F::zero(), n0, F::one()), + )) + } +} + +impl Decomposition for Matrix3 { + fn iwasawa_decomposition(self) -> Option<(Self, Self, Self)> { + let v0 = self[0]; + let mag0 = dot(v0, v0); + if mag0 <= F::zero() { + return None; + } + let n0 = dot(self[1], v0) / mag0; + let v1 = self[1] - v0 * n0; + let mag1 = dot(v1, v1); + if mag1 <= F::zero() { + return None; + } + let n1 = dot(self[2], v0) / mag0; + let n2 = dot(self[2], v1) / mag1; + let v2 = self[2] - v0 * n1 - v1 * n2; + let mag2 = dot(v2, v2); + if mag2 <= F::zero() { + return None; + } + let (a0, a1, a2) = (F::sqrt(mag0), F::sqrt(mag1), F::sqrt(mag2)); + #[rustfmt::skip] + let res = Some(( + Matrix3::from_cols(v0 / a0, v1 / a1, v2 / a2), + Matrix3::from_diagonal(Vector3::new(a0, a1, a2)), + Matrix3::new( + F::one(), F::zero(), F::zero(), + n0, F::one(), F::zero(), + n1, n2, F::one(), + ), + )); + res + } +} + +impl Decomposition for Matrix4 { + fn iwasawa_decomposition(self) -> Option<(Self, Self, Self)> { + let v0 = self[0]; + let mag0 = dot(v0, v0); + if mag0 <= F::zero() { + return None; + } + let n0 = dot(self[1], v0) / mag0; + let v1 = self[1] - v0 * n0; + let mag1 = dot(v1, v1); + if mag1 <= F::zero() { + return None; + } + let n1 = dot(self[2], v0) / mag0; + let n2 = dot(self[2], v1) / mag1; + let v2 = self[2] - v0 * n1 - v1 * n2; + let mag2 = dot(v2, v2); + if mag2 <= F::zero() { + return None; + } + let n3 = dot(self[3], v0) / mag0; + let n4 = dot(self[3], v1) / mag1; + let n5 = dot(self[3], v2) / mag2; + let v3 = self[3] - v0 * n3 - v1 * n4 - v2 * n5; + let mag3 = dot(v3, v3); + if mag3 <= F::zero() { + return None; + } + let (a0, a1, a2, a3) = (F::sqrt(mag0), F::sqrt(mag1), F::sqrt(mag2), F::sqrt(mag3)); + #[rustfmt::skip] + let res = Some(( + Matrix4::from_cols(v0 / a0, v1 / a1, v2 / a2, v3 / a3), + Matrix4::from_diagonal(Vector4::new(a0, a1, a2, a3)), + Matrix4::new( + F::one(), F::zero(), F::zero(), F::zero(), + n0, F::one(), F::zero(), F::zero(), + n1, n2, F::one(), F::zero(), + n3, n4, n5, F::one(), + ), + )); + res + } +} diff --git a/vendor/matext4cgmath/src/lib.rs b/vendor/matext4cgmath/src/lib.rs new file mode 100644 index 00000000..08c8d2a2 --- /dev/null +++ b/vendor/matext4cgmath/src/lib.rs @@ -0,0 +1,120 @@ +//! Unofficial third-party cgmath extensions for calculate eigenvalues, operator norms and Iwasawa decomposition. + +// SolveCraft: compiler warnings no longer fail the build. +#![deny(clippy::all, rust_2018_idioms)] +#![warn( + missing_docs, + missing_debug_implementations, + trivial_casts, + trivial_numeric_casts, + unsafe_code, + unstable_features, + unused_import_braces, + unused_qualifications +)] +#![allow(clippy::many_single_char_names)] + +pub use cgmath; +use cgmath::*; +use num_complex::Complex; + +mod eigens; +mod exp_decomp; +/// solvers for low dimensional algebraic equations. +pub mod solver; + +/// extension for eigen values +pub trait EigenValues: VectorSpace { + /// the type of the array of eigen values + type EigenValues; + /// calculate eigen values. + /// + /// # Examples + /// + /// ``` + /// use cgmath::*; + /// use matext4cgmath::*; + /// use num_complex::Complex; + /// const EPS: f64 = 1.0e-10; + /// + /// let mat = Matrix2::new(4.0, -2.0, 3.0, -1.0); + /// let mut eigens = mat.eigenvalues(); + /// // Even in the case of real solutions, the order in the array is not guaranteed. + /// eigens.sort_by(|x, y| x.re.partial_cmp(&y.re).unwrap()); + /// assert!(Complex::norm(eigens[0] - 1.0) < EPS); + /// assert!(Complex::norm(eigens[1] - 2.0) < EPS); + /// ``` + fn eigenvalues(self) -> Self::EigenValues; +} + +#[cfg_attr(doc, katexit::katexit)] +/// [operator norms](https://en.wikipedia.org/wiki/Matrix_norm): $L^1$, $L^2$, and $L^\infty$. +/// # Examples +/// +/// ``` +/// use cgmath::*; +/// use matext4cgmath::*; +/// +/// let mat = Matrix2::new(1.0, 3.0, -2.0, 4.0); +/// // L^1 operator norm is the maximum column absolute sumation. +/// assert_eq!(mat.norm_l1(), 6.0); +/// // L^2 operator norm is the maximum singular value. +/// let ans_norm_l2 = (5.0 + f64::sqrt(5.0)) / f64::sqrt(2.0); +/// assert!(f64::abs(mat.norm_l2() - ans_norm_l2) < 1.0e-10); +/// // L^∞ operator norm is the maximum row absolute sumation. +/// assert_eq!(mat.norm_linf(), 7.0); +/// ``` +pub trait OperatorNorm: VectorSpace { + /// operator norm for absolute value sumation: $L^1$. + fn norm_l1(self) -> Self::Scalar; + /// operator norm for Euclidean norm: $L^2$. + fn norm_l2(self) -> Self::Scalar; + /// operator norm for absolute value maximum: $L^{\infty}$. + fn norm_linf(self) -> Self::Scalar; +} + +/// calculate exponential value +pub trait Exponential: OperatorNorm + std::ops::AddAssign + One +where + Self::Scalar: BaseFloat, +{ + /// calculate exponential + /// + /// # Examples + /// + /// ``` + /// use cgmath::*; + /// use matext4cgmath::*; + /// + /// let x = Matrix2::new(0.0, 1.0, -1.0, 0.0); + /// let res = x.exp(); + /// let ans = Matrix2::from_angle(Rad(1.0)); + /// assert!((res - ans).norm_l1() < 1.0e-10); + /// ``` + fn exp(self) -> Self { + use num_traits::{Float, NumCast}; + let eps = ::epsilon(); + let mut a = self; + let mut res = Self::one(); + for i in 2_u16..=64 { + res += a; + a = a * self / ::from(i).unwrap(); + if a.norm_linf() < eps * self.norm_linf() { + break; + } + } + res + } +} + +#[cfg_attr(doc, katexit::katexit)] +/// some decompositions of matrix +pub trait Decomposition: VectorSpace { + #[cfg_attr(doc, katexit::katexit)] + /// Returns $(K, A, N)$ of the Iwasawa decomposition: $M = KAN$. + /// + /// - $K$: orthonormal matrix + /// - $A$: diagonal matrix + /// - $N$: upper-half unipotent matrix + fn iwasawa_decomposition(self) -> Option<(Self, Self, Self)>; +} diff --git a/vendor/matext4cgmath/src/solver.rs b/vendor/matext4cgmath/src/solver.rs new file mode 100644 index 00000000..8713ce3d --- /dev/null +++ b/vendor/matext4cgmath/src/solver.rs @@ -0,0 +1,246 @@ +use crate::*; + +#[cfg_attr(doc, katexit::katexit)] +/// Solve equation: $x^2 + ax + b = 0$. +/// +/// # Examples +/// +/// ``` +/// use matext4cgmath::solver; +/// use num_complex::Complex; +/// const EPS: f64 = 1.0e-10; +/// +/// let res = solver::solve_quadratic(3.0, -4.0); +/// // Real solutions are always sorted in order of solution size. +/// assert!(Complex::norm(res[0] + 4.0) < EPS); +/// assert!(Complex::norm(res[1] - 1.0) < EPS); +/// ``` +pub fn solve_quadratic(a: F, b: F) -> [Complex; 2] { + let two = F::one() + F::one(); + let four = two + two; + let det = a * a - four * b; + match det >= F::zero() { + true => { + let h = F::sqrt(det); + [ + Complex::new((-a - h) / two, F::zero()), + Complex::new((-a + h) / two, F::zero()), + ] + } + false => { + let h = F::sqrt(-det); + [Complex::new(-a, h) / two, Complex::new(-a, -h) / two] + } + } +} + +#[cfg_attr(doc, katexit::katexit)] +/// Solve equation: $x^3 + px + q = 0$. +/// +/// # Examples +/// +/// ``` +/// use matext4cgmath::solver; +/// use num_complex::Complex; +/// const EPS: f64 = 1.0e-10; +/// +/// let mut res = solver::pre_solve_cubic(-7.0, -6.0); +/// // Even in the case of real solutions, the order in the array is not guaranteed. +/// res.sort_by(|x, y| x.re.partial_cmp(&y.re).unwrap()); +/// let ans = [Complex::from(-2.0), Complex::from(-1.0), Complex::from(3.0)]; +/// res.iter().zip(ans).for_each(|(x, y)| { +/// assert!(Complex::norm(x - y) < EPS); +/// }); +/// ``` +pub fn pre_solve_cubic(p: F, q: F) -> [Complex; 3] { + let two = F::one() + F::one(); + let three = two + F::one(); + let sqrt3_2 = F::sqrt(three) / two; + let omega = Complex::new(-F::one() / two, sqrt3_2); + let omega2 = Complex::new(-F::one() / two, -sqrt3_2); + let eps_2 = F::sqrt(F::epsilon()); + + let p_3 = p / three; + let q_2 = q / two; + let alpha2 = q_2 * q_2 + p_3 * p_3 * p_3; + let (x, y) = match alpha2 >= F::zero() { + true => { + let alpha = F::sqrt(alpha2); + let tmpx = -q_2 - alpha; + let tmpy = -q_2 + alpha; + ( + Complex::new( + F::signum(tmpx) * F::powf(F::abs(tmpx), F::one() / three), + F::zero(), + ), + Complex::new( + F::signum(tmpy) * F::powf(F::abs(tmpy), F::one() / three), + F::zero(), + ), + ) + } + false => { + let alphai = F::sqrt(-alpha2); + ( + Complex::powf(Complex::new(-q_2, alphai), F::one() / three), + Complex::powf(Complex::new(-q_2, -alphai), F::one() / three), + ) + } + }; + let mut res = [x + y, omega * x + omega2 * y, omega2 * x + omega * y]; + // precision by Newton method + res.iter_mut().for_each(|x| { + let mut f = *x * *x * *x + *x * p + q; + let mut f_prime = *x * *x * three + p; + // SolveCraft: at most 100 steps. Near a double root (the eigenvalues of a + // translation matrix) the iteration can cycle forever without meeting the tolerance. + let mut steps = 0; + while f.norm() > eps_2 * f_prime.norm() && steps < 100 { + steps += 1; + if f_prime.norm() < eps_2 { + return; + } + *x -= f / f_prime; + f = *x * *x * *x + *x * p + q; + f_prime = *x * *x * three + p; + } + }); + res +} + +#[cfg_attr(doc, katexit::katexit)] +/// solve equation: $x^3 + ax^2 + bx + c = 0$. +/// +/// # Examples +/// +/// ``` +/// use matext4cgmath::solver; +/// use num_complex::Complex; +/// const EPS: f64 = 1.0e-10; +/// +/// let mut res = solver::solve_cubic(-3.0, 0.0, 4.0); +/// // Even in the case of real solutions, the order in the array is not guaranteed. +/// res.sort_by(|x, y| x.re.partial_cmp(&y.re).unwrap()); +/// let ans = [Complex::from(-1.0), Complex::from(2.0), Complex::from(2.0)]; +/// res.iter().zip(ans).for_each(|(x, y)| { +/// assert!(Complex::norm(x - y) < EPS); +/// }); +/// ``` +pub fn solve_cubic(a: F, b: F, c: F) -> [Complex; 3] { + let two = F::one() + F::one(); + let three = two + F::one(); + let twenty_seven = three * three * three; + let p = b - a * a / three; + let q = c - a * b / three + two * a * a * a / twenty_seven; + let mut res = pre_solve_cubic(p, q); + res.iter_mut().for_each(|x| { + *x -= a / three; + }); + res +} + +#[cfg_attr(doc, katexit::katexit)] +/// solve equation: $x^4 + px^2 + qx + r = 0$. +/// +/// # Examples +/// +/// ``` +/// use matext4cgmath::solver; +/// use num_complex::Complex; +/// const EPS: f64 = 1e-7; +/// +/// let mut res = solver::pre_solve_quartic(-5.0, 0.0, 4.0); +/// // Even in the case of real solutions, the order in the array is not guaranteed. +/// res.sort_by(|x, y| x.re.partial_cmp(&y.re).unwrap()); +/// let ans = [Complex::from(-2.0), Complex::from(-1.0), Complex::from(1.0), Complex::from(2.0)]; +/// res.iter().zip(ans).for_each(|(x, y)| { +/// assert!(Complex::norm(x - y) < EPS); +/// }); +/// ``` +pub fn pre_solve_quartic(p: F, q: F, r: F) -> [Complex; 4] { + let one = F::one(); + let two = one + one; + let four = two + two; + let eps_2 = F::sqrt(F::epsilon()); + + let a = two * p; + let b = p * p - four * r; + let c = -q * q; + let f = solve_cubic(a, b, c); + let a = f[0].sqrt() / two; + let b = f[1].sqrt() / two; + let c = f[2].sqrt() / two; + + let mut res = (0..8) + .map(|i| { + let a = a * F::powi(-F::one(), i % 2); + let b = b * F::powi(-F::one(), (i / 2) % 2); + let c = c * F::powi(-F::one(), (i / 4) % 2); + [-a - b - c, -a + b + c, a - b + c, a + b - c] + }) + .map(|x| { + let f = x + .iter() + .map(|t| (t * t * t * t + t * t * p + t * q + r).norm_sqr()) + .max_by(|x, y| x.partial_cmp(y).unwrap()) + .unwrap(); + (x, f) + }) + .min_by(|x, y| x.1.partial_cmp(&y.1).unwrap()) + .unwrap() + .0; + // refinement by Newton method + res.iter_mut().for_each(|x| { + let mut f = *x * *x * *x * *x + *x * *x * p + *x * q + r; + let mut f_prime = *x * *x * *x * four + *x * p * two + q; + // SolveCraft: at most 100 steps. Near a double root (the eigenvalues of a + // translation matrix) the iteration can cycle forever without meeting the tolerance. + let mut steps = 0; + while f.norm() > eps_2 * f_prime.norm() && steps < 100 { + steps += 1; + if f_prime.norm() < eps_2 { + return; + } + *x -= f / f_prime; + f_prime = *x * *x * *x * four + *x * p * two + q; + f = *x * *x * *x * *x + *x * *x * p + *x * q + r; + } + }); + res +} + +#[cfg_attr(doc, katexit::katexit)] +/// solve equation: $x^4 + ax^3 + bx^2 + cx + d = 0$. +/// +/// # Examples +/// +/// ``` +/// use matext4cgmath::solver; +/// use num_complex::Complex; +/// const EPS: f64 = 1.0e-10; +/// +/// let mut res = solver::solve_quartic(1.0, -7.0, -1.0, 6.0); +/// // Even in the case of real solutions, the order in the array is not guaranteed. +/// res.sort_by(|x, y| x.re.partial_cmp(&y.re).unwrap()); +/// let ans = [Complex::from(-3.0), Complex::from(-1.0), Complex::from(1.0), Complex::from(2.0)]; +/// res.iter().zip(ans).for_each(|(x, y)| { +/// assert!(Complex::norm(x * x * x * x + x * x * x - 7.0 * x * x - x + 6.0) < EPS); +/// assert!(Complex::norm(x - y) < EPS, "{x} {y}"); +/// }); +/// ``` +pub fn solve_quartic(a: F, b: F, c: F, d: F) -> [Complex; 4] { + let one = F::one(); + let two = one + one; + let three = one + two; + let four = two + two; + let six = two * three; + let eight = four + four; + + let a_4 = a / four; + let p = b - six * a_4 * a_4; + let q = c - two * b * a_4 + eight * a_4 * a_4 * a_4; + let r = d - c * a_4 + b * a_4 * a_4 - three * a_4 * a_4 * a_4 * a_4; + let mut res = pre_solve_quartic(p, q, r); + res.iter_mut().for_each(|x| *x -= a_4); + res +} From 3086c32a9a1b62da07ec7e42215bedffd4c13512 Mon Sep 17 00:00:00 2001 From: CypherNaugh_0x <9931495+CypherNaught-0x@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:32:48 +0200 Subject: [PATCH 13/15] A round hole elsewhere on a body no longer breaks a loft cut beside it: a loft cut from a pocket floor (556.68 x 99 down 4 mm to 80 x 80), with a 35 mm hole 66 mm away that it never touches, failed with a kernel panic in TroughFall, while the same cut without the hole, or with the hole made afterwards, worked The loft's top lies in the pocket floor. truck's boolean cannot take coincident faces, and the exact polygon fallback (planar_boolean) only takes bodies whose faces are all planar, so the hole's cylinder ruled out the only path that handles this cut. planar_boolean now first tries a sliced form: when an axis-aligned slab holds the tool and none of the body's curved faces, the body is split on the slab's planes (planes clear of the body's own faces), the planar middle piece takes the exact boolean, and the pieces outside are glued back on along the cut faces (the middle piece is healed first so its edges match theirs). Pieces in several parts are refused rather than glued into one shell. split_by_plane's half-space code is now ops::half_space, which this uses. Tests: loft_cut_beside_a_round_hole replays examples/regressions/loft_cut_beside_round_hole.json and checks the exact volume (slab - pocket - prismatoid - hole; fails on main). With the hole made after the loft, the body is the same (volume, area, 19 faces, 42 edges). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/boolean_regression_tests.rs | 10 ++ crates/kernel/src/ops.rs | 20 ++-- crates/kernel/src/polybool.rs | 106 ++++++++++++++++++ .../loft_cut_beside_round_hole.json | 24 ++++ 4 files changed, 152 insertions(+), 8 deletions(-) create mode 100644 examples/regressions/loft_cut_beside_round_hole.json diff --git a/crates/engine/src/boolean_regression_tests.rs b/crates/engine/src/boolean_regression_tests.rs index 8d8492ca..ccbb9a4b 100644 --- a/crates/engine/src/boolean_regression_tests.rs +++ b/crates/engine/src/boolean_regression_tests.rs @@ -24,6 +24,16 @@ fn slab_pocket_loft() -> (f64, f64) { (slab - 557.68 * 100.0 * 120.0, loft) } +#[test] +fn loft_cut_beside_a_round_hole() { + let s = replay(include_str!("../../../examples/regressions/loft_cut_beside_round_hole.json")); + let (pocketed, loft) = slab_pocket_loft(); + let hole = std::f64::consts::PI * 17.5 * 17.5 * 25.0; + let want = pocketed - loft - hole; + let got = volume(&s, "Body1"); + assert!((got - want).abs() < 1e-6 * want, "{got} vs {want}"); +} + #[test] fn shallow_loft_cut_from_the_top_face() { let s = replay(include_str!("../../../examples/regressions/shallow_loft_cut_from_top_face.json")); diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs index 14de038f..718a7f6d 100644 --- a/crates/kernel/src/ops.rs +++ b/crates/kernel/src/ops.rs @@ -515,6 +515,15 @@ pub fn transform(body: &Body, translate: Vec3, origin: Vec3, axis: Vec3, angle: /// Split a body with a plane: the parts on the positive and the negative side of the plane /// (only the non-empty ones). pub fn split_by_plane(body: &Body, plane: &solvecraft_geom::Plane) -> Result> { + let mut out = Vec::new(); + for positive in [true, false] { + out.extend(half_space(body, plane, positive)?); + } + Ok(out) +} + +/// The part of a body on one side of a plane (`None` when nothing is there). +pub(crate) fn half_space(body: &Body, plane: &solvecraft_geom::Plane, positive: bool) -> Result> { body.require_brep("split")?; let size = body.size(); let mut bb = solvecraft_geom::Aabb3::EMPTY; @@ -532,12 +541,7 @@ pub fn split_by_plane(body: &Body, plane: &solvecraft_geom::Plane) -> Result Result> { a.require_brep("a boolean")?; b.require_brep("a boolean")?; let (Some(pa), Some(pb)) = (planar_polys(a), planar_polys(b)) else { + if let Some(r) = sliced_planar_boolean(a, b, op) { + return r; + } return Err(KernelError::Failed("not supported yet: coincident faces on curved bodies".into())); }; let size = a.size().max(b.size()); polys_boolean(pa, pb, size, op, size * 1e-9) } +/// [`planar_boolean`] of a body with curved faces and an all-planar one, when a slab between +/// two parallel planes holds `b` and none of `a`'s curved faces (a pocket cut beside a round +/// hole): `a` is split across the slab, the middle piece takes the exact boolean, and the +/// pieces outside are joined back on. `None` when no such slab exists. +fn sliced_planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Option>> { + let pb = planar_polys(b)?; + let size = a.size().max(b.size()); + let tol = (a.size() * 1e-3).max(1e-3); + let infos = a.faces(tol).ok()?; + let mesh = a.tessellate(tol).ok()?; + let mut curved: HashMap = HashMap::new(); + for (t, fi) in mesh.triangles.iter().zip(&mesh.tri_face) { + if infos.get(*fi as usize)?.plane_normal.is_some() { + continue; + } + let bx = curved.entry(*fi).or_insert(solvecraft_geom::Aabb3::EMPTY); + for p in mesh.tri(t)? { + bx.add(p); + } + } + let mut tool = solvecraft_geom::Aabb3::EMPTY; + for p in pb.iter().flat_map(|p| &p.v) { + tool.add(*p); + } + let at = |v: Vec3, k: usize| [v.x, v.y, v.z].get(k).copied().unwrap_or(0.0); + let axes = [Vec3::X, Vec3::Y, Vec3::Z]; + // Planar faces of `a` square to an axis: a cut must not land in one of their planes. + let flat: Vec<(Vec3, Vec3)> = infos.iter().filter_map(|f| Some((f.plane_normal?, f.centroid))).collect(); + let gap = size * 1e-4; + let place = |k: usize, near: f64, far: f64| -> Option { + let axis = *axes.get(k)?; + [0.5, 0.37, 0.63, 0.25, 0.75] + .iter() + .map(|t| near + (far - near) * t) + .find(|x| !flat.iter().any(|(n, c)| n.dot(axis).abs() > 1.0 - 1e-9 && (at(*c, k) - x).abs() < gap)) + }; + // An axis along which every curved face lies clear of the tool: the cuts either side. + let (k, lo, hi) = (0..3).find_map(|k| { + let (t0, t1) = (at(tool.min, k), at(tool.max, k)); + let (mut below, mut above) = (None::, None::); + for bx in curved.values() { + let (c0, c1) = (at(bx.min, k), at(bx.max, k)); + if c1 < t0 - gap { + below = Some(below.map_or(c1, |x| x.max(c1))); + } else if c0 > t1 + gap { + above = Some(above.map_or(c0, |x| x.min(c0))); + } else { + return None; + } + } + let lo = match below { + Some(c) => Some(place(k, t0, c)?), + None => None, + }; + let hi = match above { + Some(c) => Some(place(k, t1, c)?), + None => None, + }; + Some((k, lo, hi)) + })?; + let (u, w) = match k { + 0 => (Vec3::Y, Vec3::Z), + 1 => (Vec3::Z, Vec3::X), + _ => (Vec3::X, Vec3::Y), + }; + let axis = *axes.get(k)?; + let run = || -> Result> { + let fail = |m: &str| KernelError::Failed(format!("planar boolean beside curved faces: {m}")); + let mut middle = a.clone(); + let mut outside = Vec::new(); + for (cut, keep_positive) in [(lo, true), (hi, false)] { + let Some(x) = cut else { continue }; + let plane = solvecraft_geom::Plane::new(axis * x, u, w).ok_or_else(|| fail("plane"))?; + outside.extend(crate::ops::half_space(&middle, &plane, !keep_positive)?); + middle = crate::ops::half_space(&middle, &plane, keep_positive)?.ok_or_else(|| fail("nothing beside the tool"))?; + } + let pm = planar_polys(&middle).ok_or_else(|| fail("the middle piece is not planar"))?; + let r = polys_boolean(pm, pb, size, op, size * 1e-9)?; + if op == BoolOp::Intersect { + return Ok(r); + } + // The rebuild leaves the cut faces' edges split where other polygons were cut; healed, + // they match the pieces outside edge for edge. + let mut pieces: Vec = match r { + Some(m) => vec![Body::new(crate::heal::heal_keep(m.deep_copy(), size, a.split_keep()))?], + None => Vec::new(), + }; + // Each piece outside meets the middle along its cut face (glued one shell to another). + if pieces.iter().chain(&outside).any(|x| x.solid.boundaries().len() != 1) { + return Err(fail("a piece in several parts")); + } + for o in outside { + let joined = pieces.iter().enumerate().find_map(|(i, m)| Some((i, crate::coplanar::glue(m, &o)?.ok()?))); + let (i, j) = joined.ok_or_else(|| fail("a piece does not join back"))?; + if let Some(slot) = pieces.get_mut(i) { + *slot = j; + } + } + crate::ops::join_pieces(pieces) + }; + Some(run()) +} + fn polys_boolean(pa: Vec, pb: Vec, size: f64, op: BoolOp, eps: f64) -> Result> { let polys = csg(pa, pb, op, eps)?; if polys.is_empty() { diff --git a/examples/regressions/loft_cut_beside_round_hole.json b/examples/regressions/loft_cut_beside_round_hole.json new file mode 100644 index 00000000..3816cd49 --- /dev/null +++ b/examples/regressions/loft_cut_beside_round_hole.json @@ -0,0 +1,24 @@ +{ + "description": "A loft cut from a pocket floor beside a round hole it never touches (the hole made first). Slab 900 x 480 x 160, a 557.68 x 100 pocket 120 deep, a 35 mm hole 25 deep 66 mm from the pocket, then a loft cut from a rectangle on the pocket floor (0.5 mm inside its walls) down 4 mm to an 80 x 80 square. The loft's top lies in the pocket floor, and the hole's cylinder kept the exact planar boolean from taking it: the cut failed with a kernel panic. Result: the same body as with the hole made after the loft.", + "commands": [ + {"command": "sketch.create", "params": {"plane": "XY", "offset": 690, "name": "Outline"}}, + {"command": "sketch.rectangle.two_point", "params": {"p0": [0, -480], "p1": [900, 0]}}, + {"command": "sketch.finish", "params": {}}, + {"command": "solid.extrude", "params": {"distance": 160, "name": "Slab"}}, + {"command": "sketch.create", "params": {"plane": "XY", "offset": 850, "name": "BowlTop"}}, + {"command": "sketch.rectangle.two_point", "params": {"p0": [171.16, -151.16], "p1": [728.84, -51.16]}}, + {"command": "sketch.finish", "params": {}}, + {"command": "solid.extrude", "params": {"distance": -120, "operation": "cut", "name": "BowlPocket"}}, + {"command": "sketch.create", "params": {"plane": "XY", "offset": 850, "name": "TapHole"}}, + {"command": "sketch.circle.center", "params": {"center": [795, -101.16], "radius": 17.5}}, + {"command": "sketch.finish", "params": {}}, + {"command": "solid.extrude", "params": {"distance": -25, "operation": "cut", "name": "TapCut"}}, + {"command": "sketch.create", "params": {"plane": "XY", "offset": 730, "name": "TroughInset"}}, + {"command": "sketch.rectangle.two_point", "params": {"p0": [171.66, -150.66], "p1": [728.34, -51.66]}}, + {"command": "sketch.finish", "params": {}}, + {"command": "sketch.create", "params": {"plane": "XY", "offset": 726, "name": "DrainSeat"}}, + {"command": "sketch.rectangle.two_point", "params": {"p0": [410, -141.16], "p1": [490, -61.16]}}, + {"command": "sketch.finish", "params": {}}, + {"command": "solid.loft", "params": {"sections": [{"sketch": "TroughInset"}, {"sketch": "DrainSeat"}], "operation": "cut", "name": "TroughFall"}} + ] +} From 9a2a3cef8ba769e37e71031da1e3dce5d076479e Mon Sep 17 00:00:00 2001 From: CypherNaugh_0x <9931495+CypherNaught-0x@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:33:27 +0200 Subject: [PATCH 14/15] A sweep along a path with sharp corners follows the corners: sweeping a rim profile along three lines (an open rectangle) gave a body of volume -0.0006 mm3 with its centre of mass at x = -3.5e9, and no error kernel::sweep swept segment by segment: a line translates the profile along it, an arc turns it. At a sharp corner the next line runs within the plane the profile has reached, so the profile slid sideways in its own plane and the walls folded into an inside-out, zero-volume shell. Paths whose direction changes at a joint (more than 1e-3 rad) are now swept as a polyline by sweep_path, which mitres the corners; arcs become points within 0.01 mm. Smooth paths (lines and tangent arcs) are swept as before. Pipe uses the same path. Tests: sweep_round_a_sharp_corner_is_mitred sweeps a 2 x 2 square along an L of two 10 mm lines and checks the volume, 80 mm3 (fails on main). Co-Authored-By: Claude Opus 5.5 --- crates/kernel/src/build.rs | 57 +++++++++++++++++++++++++++++++++++++- crates/kernel/src/tests.rs | 16 +++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/crates/kernel/src/build.rs b/crates/kernel/src/build.rs index 955611f6..f6fc3ba6 100644 --- a/crates/kernel/src/build.rs +++ b/crates/kernel/src/build.rs @@ -795,13 +795,68 @@ pub enum PathSeg { }, } +impl PathSeg { + /// Unit directions of the path where the segment starts and ends. + fn tangents(&self) -> Option<(Vec3, Vec3)> { + match *self { + PathSeg::Line { a, b } => { + let t = (b - a).normalized()?; + Some((t, t)) + } + PathSeg::Arc { a, center, axis, angle } => { + let k = axis.normalized()?; + let end = rotate_about(a, center, k, angle); + Some((k.cross(a - center).normalized()?, k.cross(end - center).normalized()?)) + } + } + } + + /// Points along the segment (an arc within `tol` of its chords), without its start. + fn points_after_start(&self, tol: f64) -> Vec { + match *self { + PathSeg::Line { b, .. } => vec![b], + PathSeg::Arc { a, center, axis, angle } => { + let Some(k) = axis.normalized() else { return vec![a] }; + let r = (a - center).len(); + let step = if r > tol { 2.0 * (1.0 - tol / r).acos() } else { angle.abs() }; + let n = ((angle.abs() / step.max(1e-6)).ceil() as usize).clamp(1, 512); + (1..=n).map(|i| rotate_about(a, center, k, angle * i as f64 / n as f64)).collect() + } + } + } +} + +/// `p` turned by `angle` about the unit axis `k` through `center` (right hand). +fn rotate_about(p: Vec3, center: Vec3, k: Vec3, angle: f64) -> Vec3 { + let v = p - center; + let (s, c) = angle.sin_cos(); + center + v * c + k.cross(v) * s + k * (k.dot(v) * (1.0 - c)) +} + /// Sweep a planar region (outer loop) along a chain of path segments, keeping the profile -/// perpendicular to the path (lines translate it, arcs rotate it). +/// perpendicular to the path (lines translate it, arcs rotate it). A path with sharp corners +/// is swept as a polyline, mitred at the corners. pub fn sweep(plane: &Plane, region: &Region2, path: &[PathSeg]) -> Result { region_ok(region)?; if path.is_empty() || path.len() > 1000 { return Err(KernelError::Invalid("the path needs 1…1000 segments".into())); } + // Segment by segment the profile only follows a path whose direction runs on across each + // joint: at a corner the next line would slide the profile within its own plane. + let ends: Vec<(Vec3, Vec3)> = + path.iter().map(PathSeg::tangents).collect::>().ok_or_else(|| KernelError::Invalid("a path segment has no direction".into()))?; + let corner = ends.windows(2).any(|w| w[0].1.dot(w[1].0) < (1e-3f64).cos()); + if corner { + let first = match path.first() { + Some(PathSeg::Line { a, .. } | PathSeg::Arc { a, .. }) => *a, + None => return Err(KernelError::Invalid("the path needs 1…1000 segments".into())), + }; + let mut pts = vec![first]; + for seg in path { + pts.extend(seg.points_after_start(1e-2)); + } + return crate::sweep_path::sweep_path(plane, region, &pts); + } // The outer loop and each hole, swept along the path; the ends are capped with holes. let mut starts = vec![wire(plane, ®ion.outer.ccw())?]; for h in ®ion.holes { diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs index 2dbd1c58..6a5946bb 100644 --- a/crates/kernel/src/tests.rs +++ b/crates/kernel/src/tests.rs @@ -258,6 +258,22 @@ fn loft_and_sweep() { assert!(rel(measure(&t).unwrap().volume, expect) < 1e-3, "{} vs {expect}", measure(&t).unwrap().volume); } +#[test] +fn sweep_round_a_sharp_corner_is_mitred() { + // A 2 × 2 square centred on an L of two 10 mm lines: lines alone would slide the profile + // along the second leg within its own plane (an empty, inside-out body). Mitred, the + // volume is the area times the path length. + let sq = + Region2 { outer: Loop2::polygon(&[Vec2::new(-1.0, -1.0), Vec2::new(1.0, -1.0), Vec2::new(1.0, 1.0), Vec2::new(-1.0, 1.0)]), holes: vec![] }; + let path = [ + PathSeg::Line { a: Vec3::ZERO, b: Vec3::new(0.0, 0.0, 10.0) }, + PathSeg::Line { a: Vec3::new(0.0, 0.0, 10.0), b: Vec3::new(10.0, 0.0, 10.0) }, + ]; + let b = sweep(&Plane::XY, &sq, &path).unwrap(); + let m = measure(&b).unwrap(); + assert!(rel(m.volume, 80.0) < 1e-9, "{}", m.volume); +} + #[test] fn moving_a_body_with_an_intersection_curve_edge_ends() { // A cylinder cut by a tilted box keeps an elliptical intersection curve as an edge. Moving From 1227fab8603a363f97dc5aec559d1380615d3a39 Mon Sep 17 00:00:00 2001 From: CypherNaugh_0x <9931495+CypherNaught-0x@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:33:27 +0200 Subject: [PATCH 15/15] Extrude's start_offset says which way it goes: along the sketch normal, also with direction: negative (where a positive offset starts on the far side of the sketch from the extrusion) The params doc listed start_offset? with no description, and an extrude in direction negative with a positive start offset surprised a user. The behaviour is unchanged (an offset start plane, as in Fusion); the params doc of solid.extrude and the Extent field doc say so. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/document.rs | 3 ++- crates/engine/src/cmd/features.rs | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 7b94485c..2b879010 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -125,7 +125,8 @@ pub struct Extent { /// Optional second side distance (two-sided extrude), along the negative normal. #[serde(default, skip_serializing_if = "Option::is_none")] pub distance2: Option, - /// Start offset from the sketch plane (expression). + /// Start offset from the sketch plane (expression), along the sketch normal whatever the + /// direction. #[serde(default, skip_serializing_if = "Option::is_none")] pub start_offset: Option, /// Through all bodies in the direction(s) instead of `distance`. diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index fbdb8848..60629d66 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -13,7 +13,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SOLID", "CREATE") .icon("extrude") .key("E") - .params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [[curve ids]] | [{point:[x,y]}]; face?: [x,y,z] (extrude a planar body face instead of a sketch profile); direction?: positive|negative|symmetric; distance2?; start_offset?; to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, join out of one, else new); targets?: [body]; name?; body_name?"), + .params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [[curve ids]] | [{point:[x,y]}]; face?: [x,y,z] (extrude a planar body face instead of a sketch profile); direction?: positive|negative|symmetric; distance2?; start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, join out of one, else new); targets?: [body]; name?; body_name?"), CommandSpec::new("solid.revolve", "Revolve", revolve) .at("SOLID", "CREATE") .icon("revolve")