From 9a22d9df927858d005ea4b373846ab806e17cd94 Mon Sep 17 00:00:00 2001 From: CypherNaugh_0x <9931495+CypherNaught-0x@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:10:04 +0200 Subject: [PATCH] Section Analysis takes a world coordinate: inspect.section {plane, at} places the cut at that coordinate on the named plane's axis (XY: Z, XZ: Y, YZ: X), so XZ's -Y normal no longer flips the sign; the params doc gives each named plane's normal; the result reports axis and at for named planes. at with offset, or with an {origin, normal} plane, is an error --- crates/engine/src/cmd/section.rs | 51 ++++++++++++++++++++++++-------- crates/engine/src/tests.rs | 13 ++++++++ 2 files changed, 52 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/cmd/section.rs b/crates/engine/src/cmd/section.rs index cad866b5..fcc48cf7 100644 --- a/crates/engine/src/cmd/section.rs +++ b/crates/engine/src/cmd/section.rs @@ -8,11 +8,12 @@ use super::CommandSpec; use crate::params::{bad, bool_, expr, vec3}; use crate::{Result, Session}; -pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("inspect.section", "Section Analysis", section) - .at("SOLID", "INSPECT") - .icon("section") - .noundo() - .params("plane: XY|XZ|YZ | {origin, normal}; offset?: expr along the normal; flip?: bool; or clear: true")]; +pub static COMMANDS: &[CommandSpec] = + &[CommandSpec::new("inspect.section", "Section Analysis", section).at("SOLID", "INSPECT").icon("section").noundo().params( + "plane: XY|XZ|YZ | {origin, normal}; offset?: expr along the plane's normal (XY: +Z, XZ: -Y, YZ: +X); \ + at?: expr, the cut's world coordinate on the named plane's axis (XY: Z, XZ: Y, YZ: X), instead of offset; \ + flip?: bool; or clear: true. Returns the cut's origin and normal, and for a named plane its axis and at", + )]; fn section(s: &mut Session, p: &Value) -> Result { let cmd = "inspect.section"; @@ -21,21 +22,34 @@ fn section(s: &mut Session, p: &Value) -> Result { s.revision += 1; return Ok(json!({"section": null})); } - let (origin, normal) = match p.get("plane") { + // A named plane's world axis: which component of a point lies along its normal. + let (origin, normal, axis) = match p.get("plane") { Some(Value::String(n)) => { let pl = Plane::named(n).ok_or_else(|| bad(cmd, format!("unknown plane `{n}` (XY, XZ, YZ)")))?; - (pl.origin, pl.normal()) + let normal = pl.normal(); + let axis = [normal.x, normal.y, normal.z].iter().position(|c| c.abs() > 0.5); + (pl.origin, normal, axis) } Some(o @ Value::Object(_)) => { let origin = o.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO); let n = o.get("normal").and_then(vec3).and_then(|n| n.normalized()).ok_or_else(|| bad(cmd, "the plane needs a non-zero `normal`"))?; - (origin, n) + (origin, n, None) } _ => return Err(bad(cmd, "`plane` must be XY, XZ, YZ or {origin, normal}")), }; - let offset = match expr(p, "offset") { - Some(e) => s.doc.eval(&e, solvecraft_doc::expr::Kind::Length).map_err(|e| bad(cmd, format!("offset: {e}")))?, - None => 0.0, + let length = |key: &str| -> Result> { + match expr(p, key) { + Some(e) => s.doc.eval(&e, solvecraft_doc::expr::Kind::Length).map(Some).map_err(|e| bad(cmd, format!("{key}: {e}"))), + None => Ok(None), + } + }; + let (offset, at) = (length("offset")?, length("at")?); + let offset = match (offset, at, axis) { + (Some(_), Some(_), _) => return Err(bad(cmd, "give either `offset` (along the normal) or `at` (a world coordinate), not both")), + (_, Some(_), None) => return Err(bad(cmd, "`at` needs a named plane (XY, XZ, YZ); move an {origin, normal} plane by its origin")), + // The named planes pass through the world origin, so the offset is `at` signed by the normal. + (_, Some(at), Some(i)) => at * axis_component(normal, i).signum(), + (offset, None, _) => offset.unwrap_or(0.0), }; if !origin.is_finite() || !offset.is_finite() { return Err(bad(cmd, "the plane must be finite")); @@ -44,5 +58,18 @@ fn section(s: &mut Session, p: &Value) -> Result { let origin = origin + normal * offset * if bool_(p, "flip").unwrap_or(false) { -1.0 } else { 1.0 }; s.section = Some((origin, normal)); s.revision += 1; - Ok(json!({"section": {"origin": origin, "normal": normal}})) + let mut section = json!({"origin": origin, "normal": normal}); + if let Some(i) = axis { + section["axis"] = json!(["X", "Y", "Z"].get(i).copied().unwrap_or("?")); + section["at"] = json!(axis_component(origin, i)); + } + Ok(json!({"section": section})) +} + +fn axis_component(v: Vec3, i: usize) -> f64 { + match i { + 0 => v.x, + 1 => v.y, + _ => v.z, + } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 3685ed4c..d7650630 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -637,6 +637,19 @@ fn section_analysis() { assert!(s.execute("inspect.section", &json!({"plane": {"normal": [0, 0, 0]}})).is_err()); run(&mut s, "inspect.section", json!({"clear": true})); assert!(s.section.is_none()); + // XZ's normal is -Y: an offset moves the cut to -Y, `at` places it at a world Y, and the + // result says where the cut landed either way. + let r = run(&mut s, "inspect.section", json!({"plane": "XZ", "offset": 10})); + assert_eq!((r["section"]["axis"].as_str(), r["section"]["at"].as_f64()), (Some("Y"), Some(-10.0)), "{r}"); + for (plane, at, flip) in [("XZ", -282.5, false), ("XZ", 7.0, true), ("XY", 3.0, false), ("YZ", -4.0, true)] { + let r = run(&mut s, "inspect.section", json!({"plane": plane, "at": at, "flip": flip})); + assert_eq!(r["section"]["at"].as_f64(), Some(at), "{plane} {r}"); + let (o, _) = s.section.unwrap(); + assert!(((o.x + o.y + o.z) - at).abs() < 1e-12, "{plane} {o:?}"); + } + assert!(s.execute("inspect.section", &json!({"plane": "XZ", "at": 1, "offset": 1})).is_err()); + assert!(s.execute("inspect.section", &json!({"plane": {"normal": [0, 1, 0]}, "at": 1})).is_err()); + run(&mut s, "inspect.section", json!({"clear": true})); assert!(Arc::ptr_eq(&doc, &s.doc)); assert_eq!(s.undo.len(), undo); }