From 6b4691550781e6e687e8d21c0b154c7b3b69392f Mon Sep 17 00:00:00 2001 From: lillith <35337584+0xlillith@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:32:48 +0200 Subject: [PATCH 01/17] docs: align testing guide with the CI profile --- docs/book/src/dev/testing.md | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/docs/book/src/dev/testing.md b/docs/book/src/dev/testing.md index 1be9377e..2e903b54 100644 --- a/docs/book/src/dev/testing.md +++ b/docs/book/src/dev/testing.md @@ -1,7 +1,8 @@ # Test harnesses -`cargo xtask ci` runs everything a pull request must pass: fmt, clippy with `-D warnings`, the -tests in release mode, `xtask layers`, `xtask assets` and the wasm build check. +`cargo xtask ci` runs everything a pull request must pass: fmt, `xtask assets`, +`xtask licences --check`, `xtask layers`, clippy with `-D warnings`, the tests with the `ci` +profile and the wasm build check. | Harness | Where | What it catches | |---|---|---| @@ -34,7 +35,14 @@ A new file in `crates/ui-egui/tests/scenarios/` is picked up automatically. To r before it's fixed, add a `{"pending": "why"}` step; its test is then ignored with that reason, and the fix removes the step. -## Release mode +## CI profile -CI runs the tests with `--release`, because some truck assertions only exist in debug builds -and fail on valid geometry. If a test fails only in debug, say so in the PR. +CI runs the tests with `--profile ci`: light optimisation, line tables, no LTO and debug +assertions off. Some truck debug assertions fail on valid geometry; the `ci` profile disables +them while building faster than `--release`. If a test fails only in a plain debug build, say +so in the PR. + +Run `cargo xtask test` to build with this profile and run the test binaries concurrently, +with doc tests alongside. Arguments after `--` go to each test binary, for example +`cargo xtask test -- shell`. To run the test binaries sequentially instead, use +`cargo test --workspace --profile ci`. From 347230f5d396aac7e437df2d8e6d337df84ac295 Mon Sep 17 00:00:00 2001 From: lillith <35337584+0xlillith@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:41:49 +0200 Subject: [PATCH 02/17] fix: validate fuzz seed ranges without overflow --- apps/solvecraft-cli/src/fuzz.rs | 13 ++++++----- apps/solvecraft-cli/tests/fuzz_range.rs | 31 +++++++++++++++++++++++++ docs/robustness.md | 4 ++++ 3 files changed, 42 insertions(+), 6 deletions(-) create mode 100644 apps/solvecraft-cli/tests/fuzz_range.rs diff --git a/apps/solvecraft-cli/src/fuzz.rs b/apps/solvecraft-cli/src/fuzz.rs index f8fc26b3..7a985f64 100644 --- a/apps/solvecraft-cli/src/fuzz.rs +++ b/apps/solvecraft-cli/src/fuzz.rs @@ -64,6 +64,8 @@ struct Job { pub fn run(args: &[String]) -> Result<(), String> { let from = flag(args, "--from", 0u64); let count = flag(args, "--count", 100u64); + let last = from.checked_add(count.saturating_sub(1)).ok_or("seed range exceeds u64::MAX: reduce --from or --count")?; + let mut seeds = (count > 0).then_some(from..=last).into_iter().flatten().peekable(); let jobs = flag(args, "--jobs", 4usize).clamp(1, 64); let timeout = Duration::from_secs(flag(args, "--timeout", 120u64)); let steps = flag(args, "--steps", 8usize); @@ -74,14 +76,14 @@ pub fn run(args: &[String]) -> Result<(), String> { if let Some(d) = &out_dir { std::fs::create_dir_all(d).map_err(|e| format!("{d}: {e}"))?; } - let mut next = from; let mut running: Vec = Vec::new(); let mut results: Vec = Vec::new(); let t0 = Instant::now(); - while next < from + count || !running.is_empty() { - while running.len() < jobs && next < from + count { + while seeds.peek().is_some() || !running.is_empty() { + while running.len() < jobs { + let Some(seed) = seeds.next() else { break }; let mut cmd = Command::new(&exe); - cmd.args(["fuzz-one", &next.to_string(), "--steps", &steps.to_string()]); + cmd.args(["fuzz-one", &seed.to_string(), "--steps", &steps.to_string()]); if minimise { cmd.arg("--minimise"); } @@ -96,8 +98,7 @@ pub fn run(args: &[String]) -> Result<(), String> { } }); } - running.push(Job { seed: next, child, started: Instant::now(), err: rx, last: String::new() }); - next += 1; + running.push(Job { seed, child, started: Instant::now(), err: rx, last: String::new() }); } std::thread::sleep(Duration::from_millis(50)); let mut i = 0; diff --git a/apps/solvecraft-cli/tests/fuzz_range.rs b/apps/solvecraft-cli/tests/fuzz_range.rs new file mode 100644 index 00000000..60c2ae20 --- /dev/null +++ b/apps/solvecraft-cli/tests/fuzz_range.rs @@ -0,0 +1,31 @@ +//! Seed ranges must not crash or wrap, including at the last representable seed. + +use std::process::Command; + +#[test] +fn overflowing_seed_range_is_an_error_not_a_panic() { + let output = + Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).args(["fuzz", "--from", "18446744073709551615", "--count", "2"]).output().unwrap(); + assert_eq!(output.status.code(), Some(1)); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("seed range exceeds u64::MAX"), "{stderr}"); + assert!(!stderr.contains("panicked"), "{stderr}"); +} + +#[test] +fn empty_seed_range_at_maximum_is_valid() { + let output = + Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).args(["fuzz", "--from", "18446744073709551615", "--count", "0"]).output().unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + assert!(String::from_utf8_lossy(&output.stdout).starts_with("0 seeds, 0 design steps,")); +} + +#[test] +fn maximum_seed_runs_once_without_wrapping() { + let output = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")) + .args(["fuzz", "--from", "18446744073709551615", "--count", "1", "--steps", "0", "--timeout", "0"]) + .output() + .unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + assert!(String::from_utf8_lossy(&output.stdout).starts_with("1 seeds,")); +} diff --git a/docs/robustness.md b/docs/robustness.md index 440b96bd..4639aa10 100644 --- a/docs/robustness.md +++ b/docs/robustness.md @@ -11,6 +11,10 @@ solvecraft-cli fuzz --from 0 --count 1000 --jobs 8 --timeout 90 --out runs/ --re solvecraft-cli fuzz-one --minimise # one design, cut down to the steps that matter ``` +`--count` seeds are run starting at `--from`. A range that exceeds `u64::MAX` is +rejected before any jobs or output directories are created. The maximum seed +(`18446744073709551615`) is valid with `--count 1`; `--count 0` runs no seeds. + - **Designs** (`crates/engine/src/fuzz.rs`): a seeded generator starts from a box, an extruded sketch profile closed by an arc (sometimes notched), or a revolved section (partial or full turns, sometimes touching the axis). It then adds up to eight steps: joins and cuts of From 2683b8dfed9d3eb6bd00640aafedf74862115b26 Mon Sep 17 00:00:00 2001 From: lillith <35337584+0xlillith@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:58:49 +0200 Subject: [PATCH 03/17] fix: allow horizontal and vertical alignment of sketch points --- crates/ui-egui/src/tools.rs | 29 +++++++- crates/ui-egui/tests/point_alignment.rs | 90 +++++++++++++++++++++++++ docs/book/src/guide/sketching.md | 4 ++ 3 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/tests/point_alignment.rs diff --git a/crates/ui-egui/src/tools.rs b/crates/ui-egui/src/tools.rs index adbefac1..a2a68433 100644 --- a/crates/ui-egui/src/tools.rs +++ b/crates/ui-egui/src/tools.rs @@ -14,6 +14,8 @@ pub enum Kind { Draw(usize), /// Pick sketch entities; `n` picks run the command. Pick(usize), + /// Pick one straight line, or two distinct points to align. + HorizontalVertical, Dimension, /// A tool from `sketch_tools` (it handles its own clicks). Ext, @@ -56,7 +58,7 @@ impl Tool { "sketch.slot.overall" => ("sketch.slot.overall", Kind::Draw(3)), "sketch.point" => ("sketch.point", Kind::Draw(1)), "sketch.dimension" => ("sketch.dimension", Kind::Dimension), - "sketch.constraint.horizontal_vertical" => ("sketch.constraint.horizontal_vertical", Kind::Pick(1)), + "sketch.constraint.horizontal_vertical" => ("sketch.constraint.horizontal_vertical", Kind::HorizontalVertical), "sketch.constraint.fix" => ("sketch.constraint.fix", Kind::Pick(1)), "sketch.constraint.coincident" => ("sketch.constraint.coincident", Kind::Pick(2)), "sketch.constraint.tangent" => ("sketch.constraint.tangent", Kind::Pick(2)), @@ -93,6 +95,7 @@ pub fn hint(id: &str) -> String { "sketch.arc.three_point" => "Arc: click start, end, then a point on the arc".into(), "sketch.arc.center_point" => "Arc: click centre and start, then click end or type a signed sweep".into(), "sketch.dimension" => "Dimension: pick one or two entities, then click to place the value".into(), + "sketch.constraint.horizontal_vertical" => "Horizontal/Vertical: pick a line or two points to align".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()), @@ -143,6 +146,29 @@ pub fn on_click(app: &mut SolveApp, proj: &Proj, pos: Pos2) { return; } } + Kind::HorizontalVertical => { + let hits = pick(app, proj, pos); + let hit = hits.iter().find(|hit| match hit { + Hit::SketchPoint { sketch, .. } | Hit::SketchCurve { sketch, .. } => Some(*sketch) == app.session.active_sketch, + _ => false, + }); + match hit { + Some(Hit::SketchPoint { id, .. }) => { + if let Some(first) = tool.picks.first() { + if first != id { + let _ = app.run(tool.cmd, json!({"points": [first, id]})); + tool.picks.clear(); + } + } else { + tool.picks.push(id.clone()); + } + } + Some(Hit::SketchCurve { id, straight: true, .. }) if tool.picks.is_empty() => { + let _ = app.run(tool.cmd, json!({"line": id})); + } + _ => {} + } + } Kind::Pick(n) => { let hits = pick(app, proj, pos); if let Some(id) = hits.iter().find_map(|h| match h { @@ -292,7 +318,6 @@ fn run_shape(app: &mut SolveApp, t: &Tool) -> Result, String> { fn run_pick(app: &mut SolveApp, t: &Tool) { let k = &t.picks; let params = match t.cmd { - "sketch.constraint.horizontal_vertical" => json!({"line": k[0]}), "sketch.constraint.fix" => json!({"entity": k[0]}), "sketch.constraint.coincident" => json!({"a": k[0], "b": k[1]}), "sketch.constraint.midpoint" => json!({"point": k[0], "line": k[1]}), diff --git a/crates/ui-egui/tests/point_alignment.rs b/crates/ui-egui/tests/point_alignment.rs new file mode 100644 index 00000000..45c6e126 --- /dev/null +++ b/crates/ui-egui/tests/point_alignment.rs @@ -0,0 +1,90 @@ +//! Horizontal/Vertical accepts either one line or two distinct sketch points. + +use serde_json::json; +use solvecraft_engine::sketch::ConstraintKind; +use solvecraft_ui_egui::scenario::Harness; + +#[test] +fn horizontal_vertical_aligns_origin_and_circle_center() { + for (center, horizontal) in [([20.0, 3.0], true), ([3.0, 20.0], false)] { + for origin_first in [true, false] { + let mut h = Harness::new(); + h.run(&json!([ + {"call":"engine.execute","params":{"command":"sketch.create","params":{"plane":"XY"}}}, + {"call":"engine.execute","params":{"command":"sketch.circle.center","params":{"center":center,"radius":5}}}, + {"call":"ui.view","params":{"view":"top"}}, + {"start":"sketch.constraint.horizontal_vertical"} + ])) + .unwrap(); + let (first, second) = if origin_first { ([0.0, 0.0], center) } else { (center, [0.0, 0.0]) }; + let undo = h.app.session.undo.len(); + h.run(&json!([{"click":{"sketch":first}}, {"expect":{"errors":0}}])).unwrap(); + assert_eq!(h.app.tool.as_ref().unwrap().picks.len(), 1); + assert!(!h.app.status.as_ref().is_some_and(|(_, _, error)| *error)); + assert_eq!(h.app.session.undo.len(), undo); + // Repeating the first point must not complete a self-constraint. + h.run(&json!([{"click":{"sketch":first}}, {"click":{"sketch":second}}, {"expect":{"errors":0}}])).unwrap(); + let aligned = |h: &Harness| { + let sk = &h.app.session.model.state().sketches[0].sketch; + let p = sk.point(sk.resolve_point("c1.center").unwrap()).unwrap(); + if horizontal { p.y } else { p.x } + }; + assert!(aligned(&h).abs() < 1e-6); + let sk = &h.app.session.model.state().sketches[0].sketch; + assert!(sk.constraints.iter().any(|c| if horizontal { + matches!(c.kind, ConstraintKind::HorizontalPoints { .. }) + } else { + matches!(c.kind, ConstraintKind::VerticalPoints { .. }) + })); + assert_eq!(h.app.session.undo.len(), undo + 1); + assert!(h.app.tool.as_ref().unwrap().picks.is_empty()); + h.run(&json!([{"call":"engine.execute","params":{"command":"edit.undo","params":{}}}])).unwrap(); + assert!((aligned(&h) - 3.0).abs() < 1e-6); + h.run(&json!([{"call":"engine.execute","params":{"command":"edit.redo","params":{}}}])).unwrap(); + assert!(aligned(&h).abs() < 1e-6); + } + } +} + +#[test] +fn horizontal_vertical_still_accepts_one_line() { + 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":[[10,10],[40,13]],"infer":false}}}, + {"call":"ui.view","params":{"view":"top"}}, + {"start":"sketch.constraint.horizontal_vertical"}, + {"click":{"sketch":[25,11.5]}}, + {"expect":{"errors":0}} + ])) + .unwrap(); + let sk = &h.app.session.model.state().sketches[0].sketch; + assert!(sk.constraints.iter().any(|c| matches!(c.kind, ConstraintKind::Horizontal { .. }))); + let a = sk.point(sk.resolve_point("l1.start").unwrap()).unwrap(); + let b = sk.point(sk.resolve_point("l1.end").unwrap()).unwrap(); + assert!((a.y - b.y).abs() < 1e-6); +} + +#[test] +fn horizontal_vertical_ignores_curves_and_cancels_a_pending_point() { + let mut h = Harness::new(); + h.run(&json!([ + {"call":"engine.execute","params":{"command":"sketch.create","params":{"plane":"XY"}}}, + {"call":"engine.execute","params":{"command":"sketch.circle.center","params":{"center":[20,3],"radius":5}}}, + {"call":"ui.view","params":{"view":"top"}}, + {"start":"sketch.constraint.horizontal_vertical"}, + {"click":{"sketch":[25,3]}} + ])) + .unwrap(); + assert!(h.app.tool.as_ref().unwrap().picks.is_empty()); + assert!(!h.app.status.as_ref().is_some_and(|(_, _, error)| *error)); + let undo = h.app.session.undo.len(); + h.run(&json!([{"click":{"sketch":[0,0]}}, {"click":{"sketch":[25,3]}}])).unwrap(); + assert_eq!(h.app.tool.as_ref().unwrap().picks.len(), 1); + h.run(&json!([{"key":"Escape"}])).unwrap(); + assert!(h.app.tool.is_none()); + assert_eq!(h.app.session.undo.len(), undo); + let sk = &h.app.session.model.state().sketches[0].sketch; + let p = sk.point(sk.resolve_point("c1.center").unwrap()).unwrap(); + assert!((p.y - 3.0).abs() < 1e-6); +} diff --git a/docs/book/src/guide/sketching.md b/docs/book/src/guide/sketching.md index de24f0d4..38b908ab 100644 --- a/docs/book/src/guide/sketching.md +++ b/docs/book/src/guide/sketching.md @@ -54,6 +54,10 @@ fresh link once the source is available; their stored point values are preserved ## Constraints and dimensions +**Horizontal/Vertical** accepts a click on a line or clicks on two distinct sketch points, +including the origin, endpoints and circle centres. For two points, it chooses horizontal +or vertical alignment from their current positions. Esc cancels a pending first point. + The solver is SolveCraft's own (damped Gauss–Newton with degree-of-freedom analysis). Constraints: coincident, horizontal/vertical, parallel, perpendicular, tangent, equal, concentric, collinear, midpoint, symmetry, fix, curvature (G2) and polygon. **Sketch Dimension** (D) adds linear, From 5cc65fe46bfbac10b1210e1416f4b8b5126b9c31 Mon Sep 17 00:00:00 2001 From: lillith <35337584+0xlillith@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:09:01 +0200 Subject: [PATCH 04/17] fix: apply curve-pair constraints to sketch preselection --- crates/ui-egui/src/lib.rs | 21 +++ .../ui-egui/tests/constraint_preselection.rs | 123 ++++++++++++++++++ docs/book/src/guide/sketching.md | 5 + 3 files changed, 149 insertions(+) create mode 100644 crates/ui-egui/tests/constraint_preselection.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 11577212..ff1a316c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -517,6 +517,27 @@ impl SolveApp { self.last_command = Some((spec.id.to_string(), spec.label.to_string())); } if let Some(mut t) = tools::Tool::for_command(id) { + // Curve-pair constraints can consume an exact preselection. Leave ambiguous + // selections to the ordinary pick tool; never silently constrain a subset. + if matches!( + id, + "sketch.constraint.parallel" + | "sketch.constraint.perpendicular" + | "sketch.constraint.equal" + | "sketch.constraint.tangent" + | "sketch.constraint.concentric" + | "sketch.constraint.collinear" + ) && let [solvecraft_engine::Sel::SketchCurve { id: a }, solvecraft_engine::Sel::SketchCurve { id: b }] = + self.session.selection.as_slice() + && a != b + { + let params = json!({ "a": a, "b": b }); + if self.run(id, params).is_ok() { + let _ = self.session.execute("select.clear", &json!({})); + self.set_status("Constraint applied", false); + } + return; + } if id == "sketch.dimension" { t.picks = self .session diff --git a/crates/ui-egui/tests/constraint_preselection.rs b/crates/ui-egui/tests/constraint_preselection.rs new file mode 100644 index 00000000..215d5fb5 --- /dev/null +++ b/crates/ui-egui/tests/constraint_preselection.rs @@ -0,0 +1,123 @@ +//! Constraint tools accept a selected curve pair as well as tool-first clicks. + +use serde_json::json; +use solvecraft_engine::{Sel, sketch::ConstraintKind}; +use solvecraft_ui_egui::scenario::Harness; + +fn lines() -> Harness { + 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":[[10,10],[40,10]],"infer":false}}}, + {"call":"engine.execute","params":{"command":"sketch.line","params":{"points":[[10,30],[40,35]],"infer":false}}}, + {"call":"ui.view","params":{"view":"top"}} + ])) + .unwrap(); + h +} + +fn select_lines(h: &mut Harness) { + h.run(&json!([ + {"click":{"sketch":[25,10]}}, + {"click":{"sketch":[25,32.5]},"shift":true} + ])) + .unwrap(); + assert_eq!(h.app.session.selection, vec![Sel::SketchCurve { id: "l1".into() }, Sel::SketchCurve { id: "l2".into() }]); +} + +#[test] +fn selected_lines_apply_constraints_with_one_undo_step() { + for command in ["parallel", "perpendicular", "equal", "collinear"] { + let mut h = lines(); + select_lines(&mut h); + let before = h.app.session.doc.clone(); + let undo = h.app.session.undo.len(); + h.run(&json!([{"start":format!("sketch.constraint.{command}")}])).unwrap(); + assert!(!h.app.status.as_ref().is_some_and(|(_, _, error)| *error), "{command}"); + let sk = &h.app.session.model.state().sketches[0].sketch; + assert!( + sk.constraints.iter().any(|c| match command { + "parallel" => matches!(c.kind, ConstraintKind::Parallel { .. }), + "perpendicular" => matches!(c.kind, ConstraintKind::Perpendicular { .. }), + "equal" => matches!(c.kind, ConstraintKind::Equal { .. }), + _ => matches!(c.kind, ConstraintKind::Collinear { .. }), + }), + "{command}" + ); + let after = h.app.session.doc.clone(); + assert_eq!(h.app.session.undo.len(), undo + 1); + assert!(h.app.session.selection.is_empty()); + assert!(h.app.tool.is_none()); + h.app.session.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(serde_json::to_value(&h.app.session.doc).unwrap(), serde_json::to_value(before).unwrap()); + h.app.session.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(serde_json::to_value(&h.app.session.doc).unwrap(), serde_json::to_value(after).unwrap()); + } +} + +#[test] +fn selected_circles_apply_constraints() { + for command in ["tangent", "concentric", "equal"] { + let mut h = Harness::new(); + h.run(&json!([ + {"call":"engine.execute","params":{"command":"sketch.create","params":{"plane":"XY"}}}, + {"call":"engine.execute","params":{"command":"sketch.circle.center","params":{"center":[10,10],"radius":5}}}, + {"call":"engine.execute","params":{"command":"sketch.circle.center","params":{"center":[25,12],"radius":7}}}, + {"call":"ui.view","params":{"view":"top"}}, + {"click":{"sketch":[10,15]}}, + {"click":{"sketch":[25,19]},"shift":true} + ])) + .unwrap(); + assert_eq!(h.app.session.selection.len(), 2); + let undo = h.app.session.undo.len(); + h.run(&json!([{"start":format!("sketch.constraint.{command}")}])).unwrap(); + assert!(!h.app.status.as_ref().is_some_and(|(_, _, error)| *error), "{command}"); + assert_eq!(h.app.session.undo.len(), undo + 1, "{command}"); + let sk = &h.app.session.model.state().sketches[0].sketch; + assert!(sk.constraints.iter().any(|c| match command { + "tangent" => matches!(c.kind, ConstraintKind::Tangent { .. }), + "concentric" => matches!(c.kind, ConstraintKind::Concentric { .. }), + _ => matches!(c.kind, ConstraintKind::Equal { .. }), + })); + assert!(h.app.session.selection.is_empty()); + } +} + +#[test] +fn ambiguous_selection_preserves_tool_first_workflow() { + for selection in [ + vec![], + vec![Sel::SketchCurve { id: "l1".into() }], + vec![Sel::SketchCurve { id: "l1".into() }, Sel::SketchCurve { id: "l2".into() }, Sel::SketchPoint { id: "origin".into() }], + ] { + let mut h = lines(); + h.app.session.selection = selection; + let undo = h.app.session.undo.len(); + h.run(&json!([{"start":"sketch.constraint.parallel"}])).unwrap(); + assert_eq!(h.app.session.undo.len(), undo); + assert!(h.app.tool.as_ref().unwrap().picks.is_empty()); + h.run(&json!([{"click":{"sketch":[25,10]}}, {"click":{"sketch":[25,32.5]}}])).unwrap(); + assert_eq!(h.app.session.undo.len(), undo + 1); + assert!(!h.app.status.as_ref().is_some_and(|(_, _, error)| *error)); + } +} + +#[test] +fn rejected_preselection_preserves_document_and_error() { + let mut h = lines(); + h.app.session.execute("sketch.constraint.fix", &json!({"entities":["l1","l2"]})).unwrap(); + select_lines(&mut h); + let before = serde_json::to_value(&h.app.session.doc).unwrap(); + let undo = h.app.session.undo.len(); + let selection = h.app.session.selection.clone(); + h.run(&json!([{"start":"sketch.constraint.parallel"}])).unwrap(); + assert!(h.app.status.as_ref().is_some_and(|(_, _, error)| *error)); + assert_eq!(serde_json::to_value(&h.app.session.doc).unwrap(), before); + assert_eq!(h.app.session.undo.len(), undo); + assert_eq!(h.app.session.selection, selection); + // Correct the conflict and retry without reselecting; the old error must disappear. + h.app.session.execute("sketch.constraint.fix", &json!({"entities":["l1","l2"]})).unwrap(); + h.run(&json!([{"start":"sketch.constraint.parallel"}])).unwrap(); + assert!(!h.app.status.as_ref().is_some_and(|(_, _, error)| *error)); + assert!(h.app.session.selection.is_empty()); +} diff --git a/docs/book/src/guide/sketching.md b/docs/book/src/guide/sketching.md index de24f0d4..7bb00f1b 100644 --- a/docs/book/src/guide/sketching.md +++ b/docs/book/src/guide/sketching.md @@ -60,6 +60,11 @@ midpoint, symmetry, fix, curvature (G2) and polygon. **Sketch Dimension** (D) ad aligned, horizontal, vertical, radius, diameter and angle dimensions. Each is a parameter (`d1`, `d2`, …), so it can hold an expression. +For Parallel, Perpendicular, Equal, Tangent, Concentric and Collinear, select exactly two +sketch curves (Shift-click to add the second) and choose the constraint to apply it immediately. +You can also start the constraint first and then pick its curves. An incomplete or mixed +selection starts that pick workflow; a rejected constraint keeps the selection for correction. + Under-constrained geometry is blue and fully constrained geometry turns dark, and the status shows the remaining degrees of freedom. A constraint or dimension that would over-constrain the sketch is refused rather than applied. Drag a point to move it; the solver keeps every From 7c667eda536e1bbaa0d42a374955c310d0748b4b Mon Sep 17 00:00:00 2001 From: lillith <35337584+0xlillith@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:24:42 +0200 Subject: [PATCH 05/17] test: verify constrained line geometry after preselection and redo --- .../ui-egui/tests/constraint_preselection.rs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/crates/ui-egui/tests/constraint_preselection.rs b/crates/ui-egui/tests/constraint_preselection.rs index 215d5fb5..6787aee7 100644 --- a/crates/ui-egui/tests/constraint_preselection.rs +++ b/crates/ui-egui/tests/constraint_preselection.rs @@ -25,6 +25,29 @@ fn select_lines(h: &mut Harness) { assert_eq!(h.app.session.selection, vec![Sel::SketchCurve { id: "l1".into() }, Sel::SketchCurve { id: "l2".into() }]); } +fn assert_line_geometry(h: &Harness, command: &str) { + let sk = &h.app.session.model.state().sketches[0].sketch; + let point = |name| sk.point(sk.resolve_point(name).unwrap()).unwrap(); + let a = point("l1.start"); + let b = point("l2.start"); + let u = point("l1.end") - a; + let v = point("l2.end") - b; + // Normalization also rejects a collapsed line; zero-length vectors must not + // accidentally satisfy the parallel/perpendicular checks. + let du = u.normalized().unwrap(); + let dv = v.normalized().unwrap(); + match command { + "parallel" => assert!(du.cross(dv).abs() < 1e-6), + "perpendicular" => assert!(du.dot(dv).abs() < 1e-6), + "equal" => assert!((u.len() - v.len()).abs() < 1e-6), + "collinear" => { + assert!(du.cross(dv).abs() < 1e-6); + assert!((b - a).cross(du).abs() < 1e-6); + } + _ => panic!("unexpected line constraint: {command}"), + } +} + #[test] fn selected_lines_apply_constraints_with_one_undo_step() { for command in ["parallel", "perpendicular", "equal", "collinear"] { @@ -44,6 +67,7 @@ fn selected_lines_apply_constraints_with_one_undo_step() { }), "{command}" ); + assert_line_geometry(&h, command); let after = h.app.session.doc.clone(); assert_eq!(h.app.session.undo.len(), undo + 1); assert!(h.app.session.selection.is_empty()); @@ -52,6 +76,7 @@ fn selected_lines_apply_constraints_with_one_undo_step() { assert_eq!(serde_json::to_value(&h.app.session.doc).unwrap(), serde_json::to_value(before).unwrap()); h.app.session.execute("edit.redo", &json!({})).unwrap(); assert_eq!(serde_json::to_value(&h.app.session.doc).unwrap(), serde_json::to_value(after).unwrap()); + assert_line_geometry(&h, command); } } From 8c1b1478cedff1fd9e0f3cf1d21ef7d8e33b48e4 Mon Sep 17 00:00:00 2001 From: CypherNaugh_0x <9931495+CypherNaught-0x@users.noreply.github.com> Date: Sun, 11 Oct 2026 05:33:50 +0200 Subject: [PATCH 06/17] =?UTF-8?q?Joins=20and=20cuts=20of=20round=20pads,?= =?UTF-8?q?=20bosses=20and=20holes=20across=20a=20floor=20that=20falls=20a?= =?UTF-8?q?=20degree=20or=20two=20no=20longer=20fail:=20the=20B-rep=20resu?= =?UTF-8?q?lts=20were=20right,=20but=20the=20plausibility=20check=20meshed?= =?UTF-8?q?=20them=20without=20refinement,=20and=20where=20the=20shallow?= =?UTF-8?q?=20cut=20trims=20a=20round=20side=20to=20a=20thin=20band=20the?= =?UTF-8?q?=20plain=20mesh=20spans=20it=20with=20triangles=20cutting=20thr?= =?UTF-8?q?ough=20the=20cylinder,=20so=20the=20check=20saw=20missing=20mat?= =?UTF-8?q?erial=20(0.8=20to=201.6=20%=20of=20samples=20misclassified),=20?= =?UTF-8?q?rejected=20the=20result,=20and=20the=20planar=20fallback=20then?= =?UTF-8?q?=20refused=20the=20round=20tool.=20The=20check=20now=20meshes?= =?UTF-8?q?=20the=20operands=20and=20the=20result=20with=20refinement=20on?= =?UTF-8?q?=20curved=20faces=20(as=20measure=20does).=20Test:=20round=5Fpa?= =?UTF-8?q?ds=5Fcrossing=5Fa=5Fshallow=5Ffall=20(a=201=C2=B0=20fall=20and?= =?UTF-8?q?=20a=202=C2=B0=20four-face=20fall=20toward=20a=20drain)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/kernel/src/ops.rs | 9 ++++-- crates/kernel/src/tests.rs | 59 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs index 045f48ba..7b0318a8 100644 --- a/crates/kernel/src/ops.rs +++ b/crates/kernel/src/ops.rs @@ -249,9 +249,12 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result> { let size = a.size().max(b.size()); // 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. + // there coarsely enough to fail the membership test on a correct result. The meshes are + // refined: a round side trimmed to a thin band (a short boss crossing a gently sloping + // floor) is otherwise spanned by triangles cutting through it, which reads as missing + // material. 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 meshes = (a.tessellate_with(tol_m, true).ok(), b.tessellate_with(tol_m, true).ok()); let (va, vb) = match &meshes { (Some(ma), Some(mb)) => (ma.measure().volume, mb.measure().volume), _ => (volume(a), volume(b)), @@ -260,7 +263,7 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result> { // Volume bounds, then membership against the operands (catches misclassified pieces), on // one mesh of the result. Returns (plausible, volume). let check = |r: &Body| -> (bool, f64) { - let Ok(mr) = r.tessellate(tol_m) else { return (false, f64::NAN) }; + let Ok(mr) = r.tessellate_with(tol_m, true) else { return (false, f64::NAN) }; let v = mr.measure().volume; let bounds = v > 0.0 && match op { diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs index b65c41a5..56a52453 100644 --- a/crates/kernel/src/tests.rs +++ b/crates/kernel/src/tests.rs @@ -290,6 +290,65 @@ fn moving_a_body_with_an_intersection_curve_edge_ends() { assert!(rel(v0, v1) < 1e-6, "{v0} vs {v1}"); } +/// A trough whose floor falls 1° (or 2° toward a drain, in four faces), and short round pads +/// and bosses crossing the fall. The B-rep results were right, but the plausibility check meshed +/// them without refinement: on a round side trimmed to a thin band by the shallow cut, the plain +/// mesh spans the band with triangles cutting through the cylinder, so the check saw missing +/// material, rejected the result, and the planar fallback refused the round pad. +#[test] +fn round_pads_crossing_a_shallow_fall() { + let hs = |n: Vec3, p: Vec3| { + let n = n.normalized().unwrap(); + HalfSpace { n, d: n.dot(p) } + }; + let walls = |floor: &[HalfSpace]| { + let mut h = vec![ + hs(Vec3::X, Vec3::new(290.0, 0.0, 0.0)), + hs(-Vec3::X, Vec3::new(10.0, 0.0, 0.0)), + hs(Vec3::Y, Vec3::new(0.0, 140.0, 0.0)), + hs(-Vec3::Y, Vec3::new(0.0, 10.0, 0.0)), + hs(Vec3::Z, Vec3::new(0.0, 0.0, 90.0)), + ]; + h.extend_from_slice(floor); + convex_polyhedron(&h).unwrap() + }; + let block = box_solid(Vec3::ZERO, Vec3::new(300.0, 150.0, 80.0)).unwrap(); + // One fall along x at 1°: floor z = 8 + t (x − 10). + let t = 1f64.to_radians().tan(); + let fall = boolean(&block, &walls(&[hs(Vec3::new(t, 0.0, -1.0), Vec3::new(10.0, 0.0, 8.0))]), BoolOp::Cut).unwrap().unwrap(); + let v0 = measure(&fall).unwrap().volume; + for x in [60.0, 100.0] { + // A pad from z = 9 to 12 (its bottom just under the floor, its top 2–3 mm above). + let pad = cylinder(Vec3::new(x, 75.0, 9.0), Vec3::Z, 15.0, 3.0).unwrap(); + let u = boolean(&fall, &pad, BoolOp::Union).unwrap().unwrap(); + // The part of the pad above the floor: the floor under the disc is planar, so its mean + // height is the height at the centre (where the floor is below z = 9 everywhere). + let floor = |x: f64| 8.0 + t * (x - 10.0); + let above = if floor(x - 15.0) >= 9.0 { PI * 225.0 * (12.0 - floor(x)) } else { f64::NAN }; + let v = measure(&u).unwrap().volume; + if above.is_finite() { + assert!(rel(v, v0 + above) < 1e-5, "pad at x {x}: {v} vs {}", v0 + above); + } else { + assert!(v > v0 && v < v0 + PI * 225.0 * 3.0, "pad at x {x}: {v}"); + } + } + // Four faces falling 2° toward a drain point, and a pad over a valley edge and a wall. + let t2 = 2f64.to_radians().tan(); + let d = Vec3::new(240.0, 110.0, 8.0); + let hopper = walls(&[ + hs(Vec3::new(t2, 0.0, -1.0), d), + hs(Vec3::new(-t2, 0.0, -1.0), d), + hs(Vec3::new(0.0, t2, -1.0), d), + hs(Vec3::new(0.0, -t2, -1.0), d), + ]); + let trough = boolean(&block, &hopper, BoolOp::Cut).unwrap().unwrap(); + let pad = cylinder(Vec3::new(270.0, 70.0, 8.0), Vec3::Z, 25.0, 5.0).unwrap(); + let u = boolean(&trough, &pad, BoolOp::Union).unwrap().unwrap(); + // Integrated over the disc (outside the wall at x = 290): about 6517 mm³ above the floor. + let gain = measure(&u).unwrap().volume - measure(&trough).unwrap().volume; + assert!((gain - 6517.0).abs() < 10.0, "{gain}"); +} + #[test] fn planar_booleans_with_coincident_faces() { // L-bracket: a wall flush with three sides of its base. From 3afd6ae4472aaa0d267e292ec5c6c59f02546732 Mon Sep 17 00:00:00 2001 From: Mithril-3d Date: Sun, 11 Oct 2026 12:41:41 +0900 Subject: [PATCH 07/17] feat: add multi-language (i18n) support with Japanese UI --- apps/solvecraft/src/main.rs | 1 + crates/ui-egui/src/browser.rs | 9 +- crates/ui-egui/src/context_menu.rs | 13 +- crates/ui-egui/src/dialogs.rs | 31 +- crates/ui-egui/src/documents.rs | 32 +- crates/ui-egui/src/fonts.rs | 46 +++ crates/ui-egui/src/i18n.rs | 424 +++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 8 + crates/ui-egui/src/prefs.rs | 73 +++-- crates/ui-egui/src/sketch_palette.rs | 31 +- crates/ui-egui/src/toolbar.rs | 59 ++-- 11 files changed, 647 insertions(+), 80 deletions(-) create mode 100644 crates/ui-egui/src/fonts.rs create mode 100644 crates/ui-egui/src/i18n.rs diff --git a/apps/solvecraft/src/main.rs b/apps/solvecraft/src/main.rs index f7e14d3f..0d9cd71b 100644 --- a/apps/solvecraft/src/main.rs +++ b/apps/solvecraft/src/main.rs @@ -169,6 +169,7 @@ fn main() -> eframe::Result { "SolveCraft", options, Box::new(move |cc| { + solvecraft_ui_egui::fonts::setup_fonts(&cc.egui_ctx); let mut app = SolveApp::new(Session::default(), services()); app.custom_titlebar = CUSTOM_TITLEBAR; app.integrated_titlebar = cfg!(target_os = "macos"); diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs index 5fbc0c8f..cedabc0c 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -351,7 +351,8 @@ pub(crate) fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp { x += 21.0; } let col = row.color.unwrap_or(if row.dim { t.text_dim } else { t.text }); - let galley = ui.painter().layout_no_wrap(row.label.to_string(), FontId::proportional(12.5), col); + let label_display = crate::i18n::tr_dyn(row.label); + let galley = ui.painter().layout_no_wrap(label_display, FontId::proportional(12.5), col); let lw = galley.size().x; ui.painter().galley(pos2(x, cy - galley.size().y / 2.0), galley, col); let mut bx = (x + lw + 6.0).min(r.right() - 16.0); @@ -362,9 +363,9 @@ pub(crate) fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp { } crate::scenario::publish_handle(&format!("row:{}", row.label), r.center()); let tip = match hover_on { - 1 => Some(if row.fold == Some(true) { "Collapse" } else { "Expand" }), - 2 => Some(if row.eye == Some(true) { "Hide" } else { "Show" }), - 3 => Some(if row.radio == Some(true) { "Active component" } else { "Activate" }), + 1 => Some(if row.fold == Some(true) { crate::i18n::tr("Collapse") } else { crate::i18n::tr("Expand") }), + 2 => Some(if row.eye == Some(true) { crate::i18n::tr("Hide") } else { crate::i18n::tr("Show") }), + 3 => Some(if row.radio == Some(true) { crate::i18n::tr("Active component") } else { crate::i18n::tr("Activate") }), _ => None, }; let resp = match tip { diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index 3b9ba9ab..c5dbeaaa 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -1273,7 +1273,8 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { painter.circle_filled(c, 3.0, t.text_dim); } for (k, item) in radial.iter().enumerate() { - let galley = painter.layout_no_wrap(item.label.clone(), font.clone(), if item.enabled { t.text } else { t.text_dim }); + let display_label = crate::i18n::tr(&item.label); + let galley = painter.layout_no_wrap(display_label.to_string(), font.clone(), if item.enabled { t.text } else { t.text_dim }); let size = vec2(galley.size().x + 40.0, 28.0); let rr = slot_rect(c, k, size); let resp = ui.interact(rr, ui.id().with(("radial", k)), Sense::click()); @@ -1295,7 +1296,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { picked = Some(item.clone()); } if item.enabled { - resp.on_hover_text(item.shortcut.clone()); + resp.on_hover_text(crate::i18n::tr(&item.shortcut).to_string()); } } }); @@ -1393,7 +1394,7 @@ pub fn list_row_lit(ui: &mut egui::Ui, item: &Item, lit: bool) -> Option { ui.painter().text( pos2(r.left() + 8.0, r.center().y + 2.0), Align2::LEFT_CENTER, - item.label.to_uppercase(), + crate::i18n::tr(&item.label).to_uppercase(), FontId::proportional(10.5), t.text_dim, ); @@ -1415,9 +1416,11 @@ pub fn list_row_lit(ui: &mut egui::Ui, item: &Item, lit: bool) -> Option { ); } let text = if item.enabled { t.text } else { t.text_dim.gamma_multiply(0.75) }; - ui.painter().text(pos2(r.left() + 32.0, r.center().y), Align2::LEFT_CENTER, &item.label, FontId::proportional(13.0), text); + let display_label = crate::i18n::tr(&item.label); + ui.painter().text(pos2(r.left() + 32.0, r.center().y), Align2::LEFT_CENTER, display_label, FontId::proportional(13.0), text); if !item.shortcut.is_empty() { - ui.painter().text(pos2(r.right() - 10.0, r.center().y), Align2::RIGHT_CENTER, &item.shortcut, FontId::proportional(11.5), t.text_dim); + let display_shortcut = crate::i18n::tr(&item.shortcut); + ui.painter().text(pos2(r.right() - 10.0, r.center().y), Align2::RIGHT_CENTER, display_shortcut, FontId::proportional(11.5), t.text_dim); } (item.enabled && resp.clicked()).then(|| item.clone()) } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 5038b79e..63e24a03 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -745,9 +745,10 @@ pub(crate) fn field(ui: &mut egui::Ui, s: &mut String) -> bool { pub(crate) fn combo(ui: &mut egui::Ui, id: &str, labels: &[&str], sel: &mut usize) { let row = ROW.with(|r| r.borrow().clone()); - let r = egui::ComboBox::from_id_salt(id).selected_text(labels.get(*sel).copied().unwrap_or("")).width(FIELD_W).show_ui(ui, |ui| { + let selected = labels.get(*sel).copied().map(tr).unwrap_or(""); + let r = egui::ComboBox::from_id_salt(id).selected_text(selected).width(FIELD_W).show_ui(ui, |ui| { for (i, l) in labels.iter().enumerate() { - let item = ui.selectable_value(sel, i, *l); + let item = ui.selectable_value(sel, i, tr(l)); crate::scenario::publish_handle(&format!("dialog:{row}:{l}"), item.rect.center()); } }); @@ -757,6 +758,11 @@ pub(crate) fn combo(ui: &mut egui::Ui, id: &str, labels: &[&str], sel: &mut usiz thread_local! { /// The label of the dialog row being laid out (its widgets publish `dialog: