diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f44f91d6..8f1f6d17 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 fb1b236a..3cfba562 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ exclude = ["vendor"] version = "0.2.1" edition = "2024" license = "MIT OR Apache-2.0" -rust-version = "1.90" +rust-version = "1.95" authors = ["SolidCraft contributors"] repository = "https://github.com/bherbruck/solidcraft" diff --git a/apps/solvecraft-cli/src/main.rs b/apps/solvecraft-cli/src/main.rs index 73d2c28f..44d50bca 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)); +} diff --git a/crates/ui-egui/src/tools.rs b/crates/ui-egui/src/tools.rs index 5c16b364..14d2aaae 100644 --- a/crates/ui-egui/src/tools.rs +++ b/crates/ui-egui/src/tools.rs @@ -327,6 +327,8 @@ fn run_shape(app: &mut SolveApp, t: &Tool) -> Result, String> { pub(crate) fn constraint_preselection(command: &str, selection: &[solvecraft_engine::Sel]) -> Option { use solvecraft_engine::Sel::{SketchCurve, SketchPoint}; match (command, selection) { + ("sketch.constraint.horizontal_vertical", [SketchCurve { id }]) => Some(json!({"line":id})), + ("sketch.constraint.horizontal_vertical", [SketchPoint { id: a }, SketchPoint { id: b }]) if a != b => Some(json!({"points":[a,b]})), ("sketch.constraint.coincident", [a, b]) => { let entity = |s: &solvecraft_engine::Sel| match s { SketchPoint { id } | SketchCurve { id } => Some(id.clone()), 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/getting-started.md b/docs/book/src/guide/getting-started.md index 7bd63ba0..cf990719 100644 --- a/docs/book/src/guide/getting-started.md +++ b/docs/book/src/guide/getting-started.md @@ -8,7 +8,7 @@ were released under the SolveCraft name. Download the latest build from the [releases page](https://github.com/bherbruck/solidcraft/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/solidcraft && cd solidcraft diff --git a/docs/book/src/guide/sketching.md b/docs/book/src/guide/sketching.md index 29834584..08b0d0a1 100644 --- a/docs/book/src/guide/sketching.md +++ b/docs/book/src/guide/sketching.md @@ -96,6 +96,9 @@ distinct straight lines. Two lines join their closest pair of endpoints; the fir picked line stays in place when the existing constraints allow it. Pick endpoints explicitly when you want to connect a different pair. +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