Integrate PRs #159, #166 and #174 for 0.3.0

This commit is contained in:
bherbruck committed 2026-10-11 17:23:30 -04:00
8 files changed
+135 -4

No files matched your search

+1 -1
View File
@@ -12,7 +12,7 @@ needed most.
## Get it running (about 10 minutes on a fast machine) ## 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). build you also need `rustup target add wasm32-unknown-unknown` and [`trunk`](https://trunkrs.dev).
```sh ```sh
+1 -1
View File
@@ -8,7 +8,7 @@ exclude = ["vendor"]
version = "0.2.1" version = "0.2.1"
edition = "2024" edition = "2024"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
rust-version = "1.90" rust-version = "1.95"
authors = ["SolidCraft contributors"] authors = ["SolidCraft contributors"]
repository = "https://github.com/bherbruck/solidcraft" repository = "https://github.com/bherbruck/solidcraft"
+10 -1
View File
@@ -53,8 +53,17 @@ fn main() -> ExitCode {
Some("snapshot") => cmd_snapshot(&args[1..]), Some("snapshot") => cmd_snapshot(&args[1..]),
Some("exec") => cmd_exec(&args[1..]), Some("exec") => cmd_exec(&args[1..]),
Some("commands") => { Some("commands") => {
use std::io::Write as _;
let mut s = Session::default(); 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| { Some("recipe") => args.get(1).ok_or_else(|| USAGE.to_string()).and_then(|p| {
let v = read_json(p)?; let v = read_json(p)?;
@@ -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));
}
+2
View File
@@ -327,6 +327,8 @@ fn run_shape(app: &mut SolveApp, t: &Tool) -> Result<Vec<String>, String> {
pub(crate) fn constraint_preselection(command: &str, selection: &[solvecraft_engine::Sel]) -> Option<Value> { pub(crate) fn constraint_preselection(command: &str, selection: &[solvecraft_engine::Sel]) -> Option<Value> {
use solvecraft_engine::Sel::{SketchCurve, SketchPoint}; use solvecraft_engine::Sel::{SketchCurve, SketchPoint};
match (command, selection) { 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]) => { ("sketch.constraint.coincident", [a, b]) => {
let entity = |s: &solvecraft_engine::Sel| match s { let entity = |s: &solvecraft_engine::Sel| match s {
SketchPoint { id } | SketchCurve { id } => Some(id.clone()), SketchPoint { id } | SketchCurve { id } => Some(id.clone()),
+91
View File
@@ -4,6 +4,97 @@ use serde_json::json;
use solvecraft_engine::sketch::ConstraintKind; use solvecraft_engine::sketch::ConstraintKind;
use solvecraft_ui_egui::scenario::Harness; 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] #[test]
fn horizontal_vertical_aligns_origin_and_circle_center() { fn horizontal_vertical_aligns_origin_and_circle_center() {
for (center, horizontal) in [([20.0, 3.0], true), ([3.0, 20.0], false)] { for (center, horizontal) in [([20.0, 3.0], true), ([3.0, 20.0], false)] {
+1 -1
View File
@@ -8,7 +8,7 @@ were released under the SolveCraft name.
Download the latest build from the Download the latest build from the
[releases page](https://github.com/bherbruck/solidcraft/releases), or build it yourself with [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 ```sh
git clone https://github.com/bherbruck/solidcraft && cd solidcraft git clone https://github.com/bherbruck/solidcraft && cd solidcraft
+3
View File
@@ -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 picked line stays in place when the existing constraints allow it. Pick endpoints
explicitly when you want to connect a different pair. 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 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. 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 You can also start the constraint first and then pick its curves. An incomplete or mixed