Section Analysis (FusionHalfSectionViewCommand, INSPECT): cut the view by an origin plane, construction plane or planar face with an offset (drag arrow) and flip; the inside of cut bodies shows a flat cap colour; live while the dialog is open, Remove Section on the right-click menu; viewport shader validated in a test

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 00:45:44 +00:00
1 parent 24627d4f0a
commit 1988594a13
11 files changed
+206 -6

No files matched your search

+2
View File
@@ -12,6 +12,7 @@ pub use features::auto_operation;
mod file;
mod inspect;
mod measure_sel;
mod section;
pub(crate) use measure_sel::measure_items;
mod parameters;
mod sketch;
@@ -129,6 +130,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
v.extend(parameters::COMMANDS.iter());
v.extend(file::COMMANDS.iter());
v.extend(inspect::COMMANDS.iter());
v.extend(section::COMMANDS.iter());
v
}
+48
View File
@@ -0,0 +1,48 @@
//! 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 solvecraft_geom::{Plane, Vec3};
use super::CommandSpec;
use crate::params::{bad, bool_, expr, vec3};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("FusionHalfSectionViewCommand", "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")];
fn section(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionHalfSectionViewCommand";
if bool_(p, "clear").unwrap_or(false) {
s.section = None;
s.revision += 1;
return Ok(json!({"section": null}));
}
let (origin, normal) = 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())
}
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)
}
_ => 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,
};
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.revision += 1;
Ok(json!({"section": {"origin": origin, "normal": normal}}))
}
+4
View File
@@ -156,6 +156,8 @@ pub struct Session {
world_cache: std::sync::Mutex<Option<(u64, Arc<solvecraft_doc::ModelState>)>>,
/// Features copied with `timeline.copy` (pasted by `timeline.paste`).
pub clipboard: Vec<solvecraft_doc::Feature>,
/// Section Analysis: a view cut by a plane (origin, unit normal; the normal side is hidden).
pub section: Option<(Vec3, Vec3)>,
}
const MAX_UNDO: usize = 200;
@@ -188,6 +190,7 @@ impl Session {
pending_moves: Default::default(),
world_cache: Default::default(),
clipboard: Vec::new(),
section: None,
}
}
@@ -314,6 +317,7 @@ impl Session {
pending_moves: self.pending_moves.clone(),
world_cache: Default::default(),
clipboard: self.clipboard.clone(),
section: None,
}
}
+19
View File
@@ -626,6 +626,25 @@ fn measure_items() {
assert!(s.execute("MeasureCommand", &json!({"items": [{"type": "nonsense"}]})).is_err());
}
/// Section Analysis sets and clears the view cut without touching the design or history.
#[test]
fn section_analysis() {
let mut s = Session::default();
run(&mut s, "PrimitiveBox", json!({"length": 40, "width": 30, "height": 20}));
let (doc, undo) = (s.doc.clone(), s.undo.len());
run(&mut s, "FusionHalfSectionViewCommand", json!({"plane": "XZ", "offset": 15}));
let (o, n) = s.section.unwrap();
assert!((o - n * 15.0).len() < 1e-12 && (n.y.abs() - 1.0).abs() < 1e-12);
run(&mut s, "FusionHalfSectionViewCommand", json!({"plane": {"origin": [0, 0, 5], "normal": [0, 0, 2]}, "flip": true}));
let (o, n) = s.section.unwrap();
assert!((o.z - 5.0).abs() < 1e-12 && (n.z + 1.0).abs() < 1e-12);
assert!(s.execute("FusionHalfSectionViewCommand", &json!({"plane": {"normal": [0, 0, 0]}})).is_err());
run(&mut s, "FusionHalfSectionViewCommand", json!({"clear": true}));
assert!(s.section.is_none());
assert!(Arc::ptr_eq(&doc, &s.doc));
assert_eq!(s.undo.len(), undo);
}
/// Press Pull: positive pulls a face out, negative pushes it in (a cut), edges get a fillet.
#[test]
fn press_pull() {
+6 -2
View File
@@ -30,10 +30,14 @@ pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option<Vec3>)> {
Sel::Edge { body, index, point } => (*point, st.body(body).and_then(|b| edge_bisector(&b.mesh(), *index, *point))),
Sel::Vertex { point, .. } => (*point, None),
Sel::Body { name } => (st.body(name)?.mesh().bounds().center(), None),
Sel::Plane { name } => {
let pl = solvecraft_engine::geom::Plane::named(name)?;
(pl.origin, Some(pl.normal()))
}
_ => return None,
};
Some(match d.kind {
Kind::Extrude { .. } | Kind::Fillet { .. } => (at, normal),
Kind::Extrude { .. } | Kind::Fillet { .. } | Kind::Section { .. } => (at, normal),
// Shell thickness grows into the body.
Kind::Shell { .. } => (at, normal.map(|n| -n)),
Kind::Move { .. } => (at, Some(Vec3::Z)),
@@ -118,7 +122,7 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj,
};
let symmetric = matches!(d.kind, Kind::Extrude { direction: 2, .. });
// Extrudes and moves go either way; radii and thicknesses stay positive.
let signed = matches!(d.kind, Kind::Extrude { .. } | Kind::Move { .. });
let signed = matches!(d.kind, Kind::Extrude { .. } | Kind::Move { .. } | Kind::Section { .. });
let focus = std::mem::take(&mut d.focus);
let half_height = app.cam.half_height();
let axis = revolve_axis(app, d);
+4
View File
@@ -73,6 +73,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
}
}
ui.separator();
if app.session.section.is_some() && item(ui, "Remove Section") {
other = Some("unsection");
}
if !app.session.selection.is_empty() && item(ui, "Clear Selection") {
other = Some("clear");
}
@@ -97,6 +100,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
}
match other {
Some("clear") => drop(app.run("select.clear", json!({}))),
Some("unsection") => drop(app.run("FusionHalfSectionViewCommand", json!({"clear": true}))),
Some("fit") => app.animate_view("fit"),
Some("home") => app.animate_view("home"),
Some("undo") => drop(app.run("UndoCommand", json!({}))),
+38
View File
@@ -78,6 +78,11 @@ pub enum Kind {
Sweep {
operation: usize,
},
/// Section Analysis: cut the view by a plane, moved along its normal.
Section {
offset: String,
flip: bool,
},
/// Measure one or two picked items (vertices, edges, faces): the result of the last
/// measurement and the items it was for.
Measure {
@@ -197,6 +202,9 @@ impl Dialog {
"MirrorCommand" => {
Dialog::new(Kind::Mirror, vec![SelInput::new("Bodies", BODIES, true), SelInput::new("Mirror plane", PLANES | PLANAR_FACES, false)])
}
"FusionHalfSectionViewCommand" => {
Dialog::new(Kind::Section { offset: "0 mm".into(), flip: false }, vec![SelInput::new("Plane", PLANES | PLANAR_FACES, false)])
}
"MeasureCommand" => {
Dialog::new(Kind::Measure { result: None, of: Vec::new() }, vec![SelInput::new("Items", FACES | EDGES | selection::VERTICES, true)])
}
@@ -327,6 +335,7 @@ impl Dialog {
Kind::Move { z, .. } => ("Z", ValueKind::Length, z),
Kind::PatternRect { spacing, .. } => ("Spacing", ValueKind::Length, spacing),
Kind::PatternCirc { angle, .. } => ("Angle", ValueKind::Angle, angle),
Kind::Section { offset, .. } => ("Distance", ValueKind::Length, offset),
_ => return None,
})
}
@@ -480,6 +489,7 @@ fn title(k: &Kind) -> &'static str {
Kind::Draft { .. } => "DRAFT",
Kind::Mirror => "MIRROR",
Kind::Measure { .. } => "MEASURE",
Kind::Section { .. } => "SECTION ANALYSIS",
Kind::PatternRect { .. } => "RECTANGULAR PATTERN",
Kind::PatternCirc { .. } => "CIRCULAR PATTERN",
Kind::Loft { .. } => "LOFT",
@@ -739,6 +749,14 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
}
Kind::Mirror => {}
Kind::Measure { result, .. } => measure_rows(ui, result.as_ref()),
Kind::Section { offset, flip } => {
ui.label("Distance");
enter |= field(ui, offset);
ui.end_row();
ui.label("Flip");
ui.checkbox(flip, "");
ui.end_row();
}
Kind::PatternRect { count, spacing, count2, spacing2 } => {
for (l, v) in [("Quantity", count), ("Spacing", spacing), ("Quantity 2", count2), ("Spacing 2", spacing2)] {
ui.label(l);
@@ -1194,6 +1212,26 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
json!({"sketch": sketch, "profiles": idx, "path_sketch": path_sketch, "path": path, "operation": OPS.get(*operation).copied().unwrap_or("new")}),
)
}
Kind::Section { offset, flip } => {
need(0, "a plane or planar face")?;
let plane = match sels(d, 0).first() {
Some(Sel::Plane { name }) if matches!(name.as_str(), "XY" | "XZ" | "YZ") => json!(name),
Some(Sel::Face { body, index, point }) => {
let (_, n) = planar_face(s, body, *index).ok_or("the face must be planar")?;
json!({"origin": pt(*point), "normal": pt(n)})
}
Some(Sel::Plane { name }) => {
let pl = solvecraft_engine::view::construction_planes(s)
.into_iter()
.find(|(_, n, _)| n == name)
.map(|(_, _, pl)| pl)
.ok_or("unknown plane")?;
json!({"origin": pt(pl.origin), "normal": pt(pl.normal())})
}
_ => return Err("pick a plane or a planar face".into()),
};
("FusionHalfSectionViewCommand", json!({"plane": plane, "offset": offset, "flip": flip}))
}
Kind::Move { x, y, z } => {
need(0, "bodies")?;
("FusionMoveCommand", json!({"bodies": body_names(0), "translate": [x, y, z]}))
+55 -4
View File
@@ -11,7 +11,7 @@ use egui_wgpu::wgpu::util::DeviceExt;
pub const TRI_SIZE: usize = 28;
/// Line instance: a, b (3 × f32 each), colour (4 × u8), width in pixels (f32).
pub const LINE_SIZE: usize = 32;
const UNIFORM_SIZE: u64 = 112;
const UNIFORM_SIZE: u64 = 128;
/// CPU-side geometry waiting to be uploaded. The model scene changes with the design; the
/// highlight scene (hover, selection, the origin widget) changes often and is small.
@@ -97,6 +97,10 @@ pub struct ViewportCallback {
pub back: [f32; 3],
/// Viewport size in physical pixels.
pub size_px: [f32; 2],
/// Section cut: (normal, d); fragments with normal·p > d are hidden. A zero normal: none.
pub clip: [f32; 4],
/// Colour of the inside of cut bodies (straight sRGBA, 0..1).
pub cap: [f32; 4],
}
struct Batch {
@@ -156,7 +160,13 @@ struct U {
vp: mat4x4<f32>,
back: vec4<f32>, // xyz = toward the eye, w = 1 for linear output
screen: vec4<f32>, // viewport width, height (px), depth bias
clip: vec4<f32>, // section plane: normal, d (zero normal: no section)
cap: vec4<f32>, // colour of the inside of cut bodies
};
fn clipped(p: vec3<f32>) -> bool {
return length(u.clip.xyz) > 0.5 && dot(u.clip.xyz, p) > u.clip.w;
}
@group(0) @binding(0) var<uniform> u: U;
fn to_linear(c: vec3<f32>) -> vec3<f32> {
@@ -174,6 +184,7 @@ struct TOut {
@builtin(position) pos: vec4<f32>,
@location(0) n: vec3<f32>,
@location(1) c: vec4<f32>,
@location(2) wp: vec3<f32>,
};
@vertex
@@ -182,11 +193,27 @@ fn vs_tri(@location(0) p: vec3<f32>, @location(1) n: vec3<f32>, @location(2) c:
o.pos = u.vp * vec4<f32>(p, 1.0);
o.n = n;
o.c = c;
o.wp = p;
return o;
}
/// Opaque model surfaces: cut by the section plane, the inside shows as a flat cap colour.
@fragment
fn fs_tri(i: TOut, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
fn fs_solid(i: TOut, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
if (clipped(i.wp)) { discard; }
if (!front && length(u.clip.xyz) > 0.5) {
return out_color(u.cap);
}
return shade(i);
}
@fragment
fn fs_tri(i: TOut) -> @location(0) vec4<f32> {
if (clipped(i.wp)) { discard; }
return shade(i);
}
fn shade(i: TOut) -> vec4<f32> {
// A zero normal marks flat colour (selection fills, translucent planes).
if (length(i.n) < 0.5) {
return out_color(i.c);
@@ -210,12 +237,14 @@ fn vs_ghost(@location(0) p: vec3<f32>, @location(1) n: vec3<f32>, @location(2) c
o.pos.z = o.pos.z + u.screen.z * 4.0 * o.pos.w;
o.n = n;
o.c = c;
o.wp = p;
return o;
}
struct LOut {
@builtin(position) pos: vec4<f32>,
@location(0) c: vec4<f32>,
@location(1) wp: vec3<f32>,
};
@vertex
@@ -239,11 +268,19 @@ fn vs_line(@builtin(vertex_index) vi: u32, @location(0) a: vec3<f32>, @location(
var o: LOut;
o.pos = vec4<f32>(base.xy + off / half * base.w, base.z - u.screen.z * base.w, base.w);
o.c = c;
o.wp = select(a, b, t > 0.5);
return o;
}
@fragment
fn fs_line(i: LOut) -> @location(0) vec4<f32> {
if (clipped(i.wp)) { discard; }
return out_color(i.c);
}
/// Lines the section never cuts (the grid, highlights drawn on top).
@fragment
fn fs_line_all(i: LOut) -> @location(0) vec4<f32> {
return out_color(i.c);
}
"#;
@@ -329,13 +366,13 @@ impl Resources {
};
let alpha = Some(wgpu::BlendState::ALPHA_BLENDING);
Resources {
tri: pipeline("sc_tris", "vs_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::Less, true), None),
tri: pipeline("sc_tris", "vs_tri", "fs_solid", tri_layout(), depth(wgpu::CompareFunction::Less, true), None),
tri_hl: pipeline("sc_tris_hl", "vs_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha),
trans: pipeline("sc_trans", "vs_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha),
ghost: pipeline("sc_ghost", "vs_ghost", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha),
xray: pipeline("sc_xray", "vs_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::Always, false), alpha),
line: pipeline("sc_lines", "vs_line", "fs_line", line_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha),
overlay: pipeline("sc_overlay", "vs_line", "fs_line", line_layout(), depth(wgpu::CompareFunction::Always, false), alpha),
overlay: pipeline("sc_overlay", "vs_line", "fs_line_all", line_layout(), depth(wgpu::CompareFunction::Always, false), alpha),
uniform,
bind,
linear_out: t.format.is_srgb(),
@@ -429,5 +466,19 @@ pub fn uniform_bytes(cb: &ViewportCallback, w: f32, h: f32, linear: bool) -> Vec
let mut v: Vec<f32> = cb.view_proj.iter().flatten().copied().collect();
v.extend([cb.back[0], cb.back[1], cb.back[2], if linear { 1.0 } else { 0.0 }]);
v.extend([w.max(1.0), h.max(1.0), 2e-4, 0.0]);
v.extend(cb.clip);
v.extend(cb.cap);
v.iter().flat_map(|x| x.to_le_bytes()).collect()
}
#[cfg(test)]
mod tests {
use egui_wgpu::wgpu::naga;
/// The viewport shader parses and validates (a bad shader would only fail at run time).
#[test]
fn shader_is_valid() {
let module = naga::front::wgsl::parse_str(super::SHADER).unwrap_or_else(|e| panic!("{}", e.emit_to_string(super::SHADER)));
naga::valid::Validator::new(naga::valid::ValidationFlags::all(), naga::valid::Capabilities::empty()).validate(&module).unwrap();
}
}
+5
View File
@@ -199,6 +199,11 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
pts.extend(inner);
p.add(Shape::closed_line(pts, Stroke::new(pen.w, ink)));
}
"section" => {
pen.poly(&[(4.0, 8.0), (14.0, 4.0), (20.0, 8.0), (10.0, 12.0)], pen.fill, ink);
pen.poly(&[(4.0, 8.0), (10.0, 12.0), (10.0, 20.0), (4.0, 16.0)], pen.fill, ink);
pen.poly(&[(10.0, 12.0), (20.0, 8.0), (20.0, 16.0), (10.0, 20.0)], a, ink);
}
"presspull" => {
pen.poly(&[(3.0, 15.0), (12.0, 11.0), (21.0, 15.0), (12.0, 19.0)], pen.fill, ink);
pen.arrow((12.0, 15.0), (12.0, 3.0), a);
+3
View File
@@ -83,6 +83,8 @@ pub struct Tokens {
/// Sketch points: fully constrained, free.
pub sketch_point: Color32,
pub sketch_point_free: Color32,
/// Inside of bodies cut by a section.
pub section_cap: Color32,
/// Closed sketch profiles (translucent fill).
pub profile_fill: Color32,
/// Constraint glyph boxes.
@@ -167,6 +169,7 @@ impl Tokens {
sketch_fixed: Color32::from_rgb(20, 20, 20),
sketch_point: Color32::BLACK,
sketch_point_free: Color32::from_rgb(30, 90, 200),
section_cap: Color32::from_rgb(214, 128, 72),
profile_fill: Color32::from_rgba_unmultiplied_const(255, 196, 120, 70),
glyph_bg: Color32::WHITE,
glyph_edge: Color32::from_rgb(90, 150, 90),
+22
View File
@@ -423,6 +423,26 @@ pub fn pick(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec<Hit> {
hits
}
/// The section cut shown now: the open Section Analysis dialog's plane, else the session's.
pub fn section_plane(app: &SolveApp) -> Option<(Vec3, Vec3)> {
if let Some(d) = app.dialog.as_ref().filter(|d| matches!(d.kind, crate::dialogs::Kind::Section { .. })) {
let cmds = crate::dialogs::apply_commands(app, d).ok()?;
let (_, p) = cmds.into_iter().next()?;
let mut scratch = app.session.scratch();
scratch.execute("FusionHalfSectionViewCommand", &p).ok()?;
return scratch.section;
}
app.session.section
}
/// The section as the shader's clip plane (normal, d), zero when there is none.
fn section_clip(app: &SolveApp) -> [f32; 4] {
match section_plane(app) {
Some((o, n)) => [n.x as f32, n.y as f32, n.z as f32, n.dot(o) as f32],
None => [0.0; 4],
}
}
/// Space kept free at the top right of the viewport for the view cube (docked dialogs start below).
pub const VIEW_CUBE_CLEARANCE: f32 = 168.0;
@@ -873,6 +893,8 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
hl_slot: app.viewport.hl_slot.clone(),
pv_key: app.preview.key,
pv_slot: app.preview.slot.clone(),
clip: section_clip(app),
cap: t.section_cap.to_normalized_gamma_f32(),
view_proj: proj.vp.to_f32(),
back: proj.cam.back().to_f32(),
size_px: [rect.width() * ppp, rect.height() * ppp],