diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 183d9c8f..1cfa4273 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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 } diff --git a/crates/engine/src/cmd/section.rs b/crates/engine/src/cmd/section.rs new file mode 100644 index 00000000..2623531c --- /dev/null +++ b/crates/engine/src/cmd/section.rs @@ -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 { + 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}})) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index bbb1dc83..006556cd 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -156,6 +156,8 @@ pub struct Session { world_cache: std::sync::Mutex)>>, /// Features copied with `timeline.copy` (pasted by `timeline.paste`). pub clipboard: Vec, + /// 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, } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index bbb10fdb..760ad366 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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() { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a3ee765e..582fd8ce 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -30,10 +30,14 @@ pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option)> { 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); diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index 11a33e2d..e4e2e0db 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -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!({}))), diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index f87e4fdb..4f968382 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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, 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]})) diff --git a/crates/ui-egui/src/gpu.rs b/crates/ui-egui/src/gpu.rs index 0664014d..99b43885 100644 --- a/crates/ui-egui/src/gpu.rs +++ b/crates/ui-egui/src/gpu.rs @@ -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, back: vec4, // xyz = toward the eye, w = 1 for linear output screen: vec4, // viewport width, height (px), depth bias + clip: vec4, // section plane: normal, d (zero normal: no section) + cap: vec4, // colour of the inside of cut bodies }; + +fn clipped(p: vec3) -> bool { + return length(u.clip.xyz) > 0.5 && dot(u.clip.xyz, p) > u.clip.w; +} @group(0) @binding(0) var u: U; fn to_linear(c: vec3) -> vec3 { @@ -174,6 +184,7 @@ struct TOut { @builtin(position) pos: vec4, @location(0) n: vec3, @location(1) c: vec4, + @location(2) wp: vec3, }; @vertex @@ -182,11 +193,27 @@ fn vs_tri(@location(0) p: vec3, @location(1) n: vec3, @location(2) c: o.pos = u.vp * vec4(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 { +fn fs_solid(i: TOut, @builtin(front_facing) front: bool) -> @location(0) vec4 { + 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 { + if (clipped(i.wp)) { discard; } + return shade(i); +} + +fn shade(i: TOut) -> vec4 { // 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, @location(1) n: vec3, @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, @location(0) c: vec4, + @location(1) wp: vec3, }; @vertex @@ -239,11 +268,19 @@ fn vs_line(@builtin(vertex_index) vi: u32, @location(0) a: vec3, @location( var o: LOut; o.pos = vec4(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 { + 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 { 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 = 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(); + } +} diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 5eef3dcc..ce9517bd 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -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); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index b304c5f3..e4f02fbc 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -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), diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 5e5d8fab..4a70f6dd 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -423,6 +423,26 @@ pub fn pick(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec { 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],