Files
solvecraft/crates/engine/src/cmd/section.rs
T
SS-EN 769dfff1a5 Section Analysis: a Hatch option (#4563) (#4565)
* claim: #4563

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Section Analysis: a Hatch option, off for a plain cut in the body's colour (#4563)

The Section Analysis dialog gets a Hatch check box beside Flip, and
inspect.section a hatch?: bool (default true), so scripts, the CLI and the
control channel reach it too. Hatched, a cut is its body's colour tinted
toward the cap colour with 45 degree stripes, as before; off, it is the
body's own solid colour. The viewport (cap alpha in the uniform), the CPU
rasterizer and ui.render follow it, and the section keeps it when hidden
and shown again or edited.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:45:23 +00:00

92 lines
4.7 KiB
Rust

//! Section Analysis: a view-only cut through the model by a plane (what is on the plane's
//! positive side is hidden). It changes no geometry and is not an undo step.
use serde_json::{Value, json};
use solidcraft_geom::{Plane, Vec3};
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 | construction plane name or id | {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; hatch?: bool (hatch the cut faces, in each body's colour; default true); 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<Value> {
let cmd = "inspect.section";
if bool_(p, "clear").unwrap_or(false) {
s.section = None;
s.revision += 1;
return Ok(json!({"section": null}));
}
// 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)) if Plane::named(n.trim()).is_some() => {
// Apply the same ambiguity check as modelling commands before treating a name as an origin alias.
super::features::plane_param(s, p.get("plane"), cmd)?;
let pl = Plane::named(n.trim()).ok_or_else(|| bad(cmd, format!("unknown plane `{n}`")))?;
let normal = pl.normal();
let axis = [normal.x, normal.y, normal.z].iter().position(|c| c.abs() > 0.5);
(pl.origin, normal, axis)
}
// A construction plane (name or id): where it is now.
Some(v @ (Value::String(_) | Value::Number(_))) => {
let r = super::features::plane_param(s, Some(v), cmd)?;
let solidcraft_doc::PlaneRef::Construction { name } = &r else { return Err(bad(cmd, "`plane` must be a plane")) };
let st = s.model.state();
let pl = st
.construct
.iter()
.find(|c| &c.name == name)
.and_then(|c| if let solidcraft_doc::construct::ConstructGeom::Plane(pl) = &c.geom { Some(*pl) } else { None })
.ok_or_else(|| bad(cmd, format!("construction plane `{name}` is not built (rolled back, suppressed or failed)")))?;
(pl.origin, pl.normal(), None)
}
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, None)
}
_ => return Err(bad(cmd, "`plane` must be XY, XZ, YZ, a construction plane name or id, or {origin, normal}")),
};
let length = |key: &str| -> Result<Option<f64>> {
match expr(p, key) {
Some(e) => s.doc.eval(&e, solidcraft_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"));
}
let normal = if bool_(p, "flip").unwrap_or(false) { -normal } else { normal };
let origin = origin + normal * offset * if bool_(p, "flip").unwrap_or(false) { -1.0 } else { 1.0 };
s.section = Some((origin, normal));
s.section_hatch = bool_(p, "hatch").unwrap_or(true);
s.revision += 1;
let mut section = json!({"origin": origin, "normal": normal, "hatch": s.section_hatch});
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,
}
}