diff --git a/ROADMAP.md b/ROADMAP.md index a91229c6..40fb20cc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -66,6 +66,9 @@ headline counts in-scope tabs only and lists the deferred ones separately. - Repeating the initial point of a planar multi-point sketch drawing tool is neutral: the tool waits for a distinct point without a degenerate-shape error or history entry. Typed input and programmatic commands retain their validation. +- Circular-pattern and angled-plane axes reject explicitly supplied malformed or expression + origins instead of silently rotating about zero. Numeric origins remain in millimetres; + associative axis expressions and construction-axis references remain unsupported. - Single-line and rectangle-side dimensioning supports selecting a line or its two endpoints. Type a value and press Enter to commit one undo step; rejected values remain editable and cancellation leaves the sketch unchanged. Fit keeps geometry clear of the Sketch Palette diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 76f6e0a9..f423c476 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -854,7 +854,12 @@ fn axis_line(p: &Value, cmd: &str) -> Result<(Vec3, Vec3)> { _ => Err(bad(cmd, "axis must be X, Y, Z or {origin, dir}")), }, Some(o @ Value::Object(_)) => { - let origin = o.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO); + let origin = match o.get("origin") { + None => Vec3::ZERO, + Some(v) => vec3(v).ok_or_else(|| { + bad(cmd, "axis.origin must contain three finite numeric coordinates in millimetres; expressions are not supported") + })?, + }; let dir = o.get("dir").and_then(vec3).and_then(|d| d.normalized()).ok_or_else(|| bad(cmd, "axis needs a non-zero `dir`"))?; Ok((origin, dir)) } diff --git a/crates/engine/src/cmd/patterns_tests.rs b/crates/engine/src/cmd/patterns_tests.rs index 09eb681e..895b2581 100644 --- a/crates/engine/src/cmd/patterns_tests.rs +++ b/crates/engine/src/cmd/patterns_tests.rs @@ -34,6 +34,71 @@ fn plate_with_hole() -> Session { const PLATE: f64 = 100.0 * 50.0 * 5.0; const HOLE: f64 = PI * 4.0 * 5.0; +#[test] +fn circular_axis_origin_rejects_invalid_values_atomically() { + let mut s = plate_with_hole(); + run(&mut s, "parameters.add", json!({"name":"cx","expression":"50 mm"})); + run(&mut s, "parameters.add", json!({"name":"cy","expression":"25 mm"})); + let before = s.doc.to_json(); + let undo = s.undo.len(); + for origin in [ + json!(["cx", "cy", 0]), + json!(["50", 25, 0]), + json!([0, 0]), + json!([0, 0, 0, 0]), + json!(null), + json!(false), + json!({"x":0,"y":0}), + json!([1e9, 0, 0]), + json!([0, 1e100, 0]), + ] { + for (cmd, params) in [ + ("solid.pattern.circular", json!({"features":["Hole1"],"axis":{"origin":origin,"dir":[0,0,1]},"count":4})), + ("construct.plane.at_angle", json!({"base":"XY","axis":{"origin":origin,"dir":[1,0,0]},"angle":45})), + ] { + let error = s.execute(cmd, ¶ms).unwrap_err().to_string(); + assert!(error.contains("axis.origin") && error.contains("expressions are not supported"), "{error}"); + assert_eq!(s.doc.to_json(), before); + assert_eq!(s.undo.len(), undo); + } + } +} + +#[test] +fn circular_numeric_axis_origin_preserves_hole_geometry_and_reopens() { + for origin in [json!([50, 50, 0]), json!({"x":50,"y":50,"z":0})] { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane":"XY"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[100,100]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance":10})); + run(&mut s, "sketch.create", json!({"plane":"XY"})); + run(&mut s, "sketch.circle.center", json!({"center":[50,20],"radius":5})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance":10,"operation":"cut","name":"Hole"})); + let before = s.doc.to_json(); + run(&mut s, "solid.pattern.circular", json!({"features":["Hole"],"axis":{"origin":origin,"dir":[0,0,1]},"count":4})); + let expected = 100.0 * 100.0 * 10.0 - 4.0 * PI * 25.0 * 10.0; + assert!(rel(volume(&mut s), expected) < 1e-4); + let saved = s.doc.to_json(); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.to_json(), before); + run(&mut s, "edit.redo", json!({})); + assert_eq!(s.doc.to_json(), saved); + let mut reopened = Session::new(solvecraft_doc::Document::from_json(&saved).unwrap()); + assert!(rel(volume(&mut reopened), expected) < 1e-4); + } + // The documented omission of origin still means zero. + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"corner":[20,0,0],"body_name":"Cube"})); + let before = s.doc.to_json(); + run(&mut s, "solid.pattern.circular", json!({"bodies":["Cube"],"axis":{"dir":[0,0,1]},"count":4})); + let implicit = s.doc.to_json(); + let mut explicit = Session::new(solvecraft_doc::Document::from_json(&before).unwrap()); + run(&mut explicit, "solid.pattern.circular", json!({"bodies":["Cube"],"axis":{"origin":[0,0,0],"dir":[0,0,1]},"count":4})); + assert_eq!(implicit, explicit.doc.to_json()); +} + #[test] fn pattern_on_path_spacing_extent_and_flip() { let mut s = plate_with_hole(); diff --git a/docs/book/src/guide/features.md b/docs/book/src/guide/features.md index e940728e..35dd3c9e 100644 --- a/docs/book/src/guide/features.md +++ b/docs/book/src/guide/features.md @@ -62,6 +62,12 @@ be found, the feature warns instead of silently picking another face. ## Editing a feature from a script +For `solid.pattern.circular`, `axis` accepts `X`, `Y`, `Z`, or `{origin, dir}`. +An explicit origin must contain three finite numeric coordinates in millimetres +(`[x, y, z]` or `{x, y, z}`); omitting it uses the world origin. Expressions and +construction-axis references are not supported. Invalid origins are rejected rather than +silently replaced with zero. + `timeline.inspect {feature}` returns a feature's `definition`: the fields `timeline.edit` changes, with their current values. `timeline.edit {feature, set}` merges `set` into that definition (objects are merged field by field, anything else replaces the value), rebuilds the feature and