From 09bf9cafdfd0ab0709445bb81d943e0cc98dcdb8 Mon Sep 17 00:00:00 2001 From: lillith <35337584+0xlillith@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:51:33 +0200 Subject: [PATCH 1/3] fix: apply horizontal and vertical constraints to preselection --- crates/ui-egui/src/lib.rs | 15 ++++ crates/ui-egui/tests/point_alignment.rs | 91 +++++++++++++++++++++++++ docs/book/src/guide/sketching.md | 3 + 3 files changed, 109 insertions(+) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4e726eb8..9441e80a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -545,6 +545,21 @@ impl SolveApp { self.last_command = Some((spec.id.to_string(), spec.label.to_string())); } if let Some(mut t) = tools::Tool::for_command(id) { + if id == "sketch.constraint.horizontal_vertical" { + use solvecraft_engine::Sel; + let params = match self.session.selection.as_slice() { + [Sel::SketchCurve { id }] => Some(json!({ "line": id })), + [Sel::SketchPoint { id: a }, Sel::SketchPoint { id: b }] if a != b => Some(json!({ "points": [a, b] })), + _ => None, + }; + if let Some(params) = params { + if self.run(id, params).is_ok() { + let _ = self.session.execute("select.clear", &json!({})); + self.set_status("Constraint applied", false); + } + return; + } + } // Curve-pair constraints can consume an exact preselection. Leave ambiguous // selections to the ordinary pick tool; never silently constrain a subset. if matches!( diff --git a/crates/ui-egui/tests/point_alignment.rs b/crates/ui-egui/tests/point_alignment.rs index 45c6e126..034dcc59 100644 --- a/crates/ui-egui/tests/point_alignment.rs +++ b/crates/ui-egui/tests/point_alignment.rs @@ -4,6 +4,97 @@ use serde_json::json; use solvecraft_engine::sketch::ConstraintKind; use solvecraft_ui_egui::scenario::Harness; +#[test] +fn horizontal_vertical_uses_preselected_line_and_preserves_rejection() { + for fixed 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":[[10,10],[40,13]],"infer":false}}}, + {"call":"ui.view","params":{"view":"top"}} + ])) + .unwrap(); + if fixed { + h.app.session.execute("sketch.constraint.fix", &json!({"entity":"l1"})).unwrap(); + } + h.run(&json!([{"click":{"sketch":[25,11.5]}}])).unwrap(); + assert_eq!(h.app.session.selection.len(), 1); + let selection = h.app.session.selection.clone(); + let before = serde_json::to_value(&h.app.session.doc).unwrap(); + let undo = h.app.session.undo.len(); + h.run(&json!([{"start":"sketch.constraint.horizontal_vertical"}])).unwrap(); + assert_eq!(h.app.status.as_ref().is_some_and(|(_, _, error)| *error), fixed); + if fixed { + assert_eq!(serde_json::to_value(&h.app.session.doc).unwrap(), before); + assert_eq!(h.app.session.selection, selection); + assert_eq!(h.app.session.undo.len(), undo); + } else { + let sk = &h.app.session.model.state().sketches[0].sketch; + 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); + assert_eq!(h.app.session.undo.len(), undo + 1); + assert!(h.app.session.selection.is_empty()); + } + assert!(h.app.tool.is_none()); + } +} + +#[test] +fn horizontal_vertical_leaves_ambiguous_preselection_to_pick_tool() { + use solvecraft_engine::Sel; + for selection in [ + vec![Sel::SketchPoint { id: "origin".into() }], + vec![Sel::SketchPoint { id: "origin".into() }, Sel::SketchCurve { id: "c1".into() }], + vec![Sel::SketchPoint { id: "origin".into() }, Sel::SketchPoint { id: "c1.center".into() }, Sel::SketchCurve { id: "c1".into() }], + ] { + let mut h = Harness::new(); + h.app.session.execute("sketch.create", &json!({"plane":"XY"})).unwrap(); + h.app.session.execute("sketch.circle.center", &json!({"center":[20,3],"radius":5})).unwrap(); + h.app.session.selection = selection.clone(); + let undo = h.app.session.undo.len(); + h.run(&json!([{"start":"sketch.constraint.horizontal_vertical"}])).unwrap(); + assert!(h.app.tool.as_ref().unwrap().picks.is_empty()); + assert_eq!(h.app.session.undo.len(), undo); + assert_eq!(h.app.session.selection, selection); + assert!(!h.app.status.as_ref().is_some_and(|(_, _, error)| *error)); + } +} + +#[test] +fn horizontal_vertical_uses_preselected_points() { + for center in [[20.0, 3.0], [3.0, 20.0]] { + for reverse in [false, true] { + let mut h = Harness::new(); + let (first, second) = if reverse { (center, [0.0, 0.0]) } else { ([0.0, 0.0], center) }; + 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"}}, + {"click":{"sketch":first}}, + {"click":{"sketch":second},"shift":true} + ])) + .unwrap(); + assert_eq!(h.app.session.selection.len(), 2); + let before = serde_json::to_value(&h.app.session.doc).unwrap(); + let undo = h.app.session.undo.len(); + h.run(&json!([{"start":"sketch.constraint.horizontal_vertical"}])).unwrap(); + assert!(!h.app.status.as_ref().is_some_and(|(_, _, error)| *error)); + let sk = &h.app.session.model.state().sketches[0].sketch; + let p = sk.point(sk.resolve_point("c1.center").unwrap()).unwrap(); + assert!((if center[0] > center[1] { p.y } else { p.x }).abs() < 1e-6); + assert!(h.app.tool.is_none()); + assert!(h.app.session.selection.is_empty()); + assert_eq!(h.app.session.undo.len(), undo + 1); + let after = serde_json::to_value(&h.app.session.doc).unwrap(); + h.app.session.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(serde_json::to_value(&h.app.session.doc).unwrap(), before); + h.app.session.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(serde_json::to_value(&h.app.session.doc).unwrap(), after); + } + } +} + #[test] fn horizontal_vertical_aligns_origin_and_circle_center() { for (center, horizontal) in [([20.0, 3.0], true), ([3.0, 20.0], false)] { diff --git a/docs/book/src/guide/sketching.md b/docs/book/src/guide/sketching.md index 096a3bfb..009f15ce 100644 --- a/docs/book/src/guide/sketching.md +++ b/docs/book/src/guide/sketching.md @@ -91,6 +91,9 @@ 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. +Horizontal/Vertical also applies immediately to one selected line or two distinct selected +points. Shift-click to add the second point before choosing the constraint. + 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 From 36f49811ab4f6d6b9e5ef27408d6f51ad14e3cb1 Mon Sep 17 00:00:00 2001 From: lillith <35337584+0xlillith@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:37:06 +0200 Subject: [PATCH 2/3] chore: align advertised Rust version with egui requirements --- CONTRIBUTING.md | 2 +- Cargo.toml | 2 +- docs/book/src/guide/getting-started.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7fc67cff..49f0c7dc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,7 +12,7 @@ needed most. ## Get it running (about 10 minutes on a fast machine) -You need stable Rust 1.90 or newer. For the web +You need stable Rust 1.95 or newer (required by egui/eframe 0.36). For the web build you also need `rustup target add wasm32-unknown-unknown` and [`trunk`](https://trunkrs.dev). ```sh diff --git a/Cargo.toml b/Cargo.toml index 7069ee39..49b1cf9e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ exclude = ["vendor"] version = "0.2.0" edition = "2024" license = "MIT OR Apache-2.0" -rust-version = "1.90" +rust-version = "1.95" authors = ["SolveCraft contributors"] repository = "https://github.com/bherbruck/solvecraft" diff --git a/docs/book/src/guide/getting-started.md b/docs/book/src/guide/getting-started.md index 9112935e..a8f36803 100644 --- a/docs/book/src/guide/getting-started.md +++ b/docs/book/src/guide/getting-started.md @@ -4,7 +4,7 @@ Download the latest build from the [releases page](https://github.com/bherbruck/solvecraft/releases), or build it yourself with -stable Rust (1.90 or newer): +stable Rust (1.95 or newer): ```sh git clone https://github.com/bherbruck/solvecraft && cd solvecraft From adecca41f63b967777ddf6afc5a250b5ad3bfaee Mon Sep 17 00:00:00 2001 From: lillith <35337584+0xlillith@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:17:23 +0200 Subject: [PATCH 3/3] fix: handle closed stdout pipes in CLI command listing --- apps/solvecraft-cli/src/main.rs | 11 ++++++++- apps/solvecraft-cli/tests/commands_output.rs | 26 ++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 apps/solvecraft-cli/tests/commands_output.rs diff --git a/apps/solvecraft-cli/src/main.rs b/apps/solvecraft-cli/src/main.rs index 0f748220..da7d5e75 100644 --- a/apps/solvecraft-cli/src/main.rs +++ b/apps/solvecraft-cli/src/main.rs @@ -53,8 +53,17 @@ fn main() -> ExitCode { Some("snapshot") => cmd_snapshot(&args[1..]), Some("exec") => cmd_exec(&args[1..]), Some("commands") => { + use std::io::Write as _; let mut s = Session::default(); - s.execute("engine.commands", &json!({})).map(|v| println!("{}", pretty(&v))).map_err(|e| e.to_string()) + s.execute("engine.commands", &json!({})).map_err(|e| e.to_string()).and_then(|v| { + // A pipeline consumer may stop early (e.g. `commands | head`). + // Unlike println!, writing explicitly lets us handle that without a panic. + match writeln!(std::io::stdout().lock(), "{}", pretty(&v)) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => Ok(()), + Err(e) => Err(format!("stdout: {e}")), + } + }) } Some("recipe") => args.get(1).ok_or_else(|| USAGE.to_string()).and_then(|p| { let v = read_json(p)?; diff --git a/apps/solvecraft-cli/tests/commands_output.rs b/apps/solvecraft-cli/tests/commands_output.rs new file mode 100644 index 00000000..7637ba88 --- /dev/null +++ b/apps/solvecraft-cli/tests/commands_output.rs @@ -0,0 +1,26 @@ +//! The registry remains valid JSON and a closed pipeline reader must not cause a panic. + +use std::process::{Command, Stdio}; + +#[test] +fn registry_output_is_complete_json() { + let output = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).arg("commands").output().unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + let registry: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + let commands = registry.as_array().unwrap(); + assert!(commands.iter().any(|command| command["id"] == "solid.box")); + assert!(commands.iter().any(|command| command["id"] == "engine.commands")); + assert!(output.stdout.ends_with(b"\n")); + assert!(output.stderr.is_empty()); +} + +#[test] +fn closed_registry_pipe_exits_without_panicking() { + let mut child = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).arg("commands").stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap(); + // Close our read end before waiting. No sleeps or timing threshold are needed: + // the registry is larger than the pipe buffer, so it cannot all be written first. + drop(child.stdout.take()); + let output = child.wait_with_output().unwrap(); + assert!(output.status.success(), "status: {}; stderr: {}", output.status, String::from_utf8_lossy(&output.stderr)); + assert!(output.stderr.is_empty(), "{}", String::from_utf8_lossy(&output.stderr)); +}