From dea1f46266faecefa29a34fa14c337f4e2521454 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 08:01:12 +0000 Subject: [PATCH] Zebra, Draft and Curvature Map analyses (view state on the session, no undo step): the GPU shades the model's faces with reflected stripes, draft-angle bands to a pull direction (green, yellow within the angle, red) or a blue-green-red curvature map; the toolbar button toggles each Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/surface_analysis.rs | 97 +++++++++++++++++++++++ crates/engine/src/lib.rs | 16 ++++ crates/ui-egui/src/gpu.rs | 44 +++++++++- crates/ui-egui/src/icons.rs | 29 +++++++ crates/ui-egui/src/sketch_tools.rs | 18 ++++- crates/ui-egui/src/viewport.rs | 12 +++ 7 files changed, 216 insertions(+), 2 deletions(-) create mode 100644 crates/engine/src/cmd/surface_analysis.rs diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 1897377b..1cb2d25e 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -30,6 +30,7 @@ mod sketch_import; mod sketch_inspect; mod sketch_modify; mod sketch_project; +mod surface_analysis; use serde::Serialize; use serde_json::Value; @@ -132,6 +133,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> { v.extend(sketch_inspect::COMMANDS.iter()); v.extend(sketch_canvas::COMMANDS.iter()); v.extend(sketch_3d::COMMANDS.iter()); + v.extend(surface_analysis::COMMANDS.iter()); v.extend(sketch_modify::COMMANDS.iter()); v.extend(sketch_project::COMMANDS.iter()); v.extend(features::COMMANDS.iter()); diff --git a/crates/engine/src/cmd/surface_analysis.rs b/crates/engine/src/cmd/surface_analysis.rs new file mode 100644 index 00000000..61450d9f --- /dev/null +++ b/crates/engine/src/cmd/surface_analysis.rs @@ -0,0 +1,97 @@ +//! Surface analyses: zebra stripes, draft angle and curvature map shading of the model's faces. +//! View state only: no geometry changes and no undo step. + +use serde_json::{Value, json}; +use solvecraft_geom::Vec3; + +use super::CommandSpec; +use crate::params::{bad, bool_, num, vec3}; +use crate::{Result, Session, SurfaceAnalysis}; + +pub static COMMANDS: &[CommandSpec] = &[ + CommandSpec::new("FusionZebraAnalysisCommand", "Zebra Analysis", zebra) + .at("SKETCH", "INSPECT") + .icon("zebra") + .noundo() + .params("stripes?: number across the view (default 12); clear?: true turns the analysis off"), + CommandSpec::new("FusionDraftAnalysisCommand", "Draft Analysis", draft) + .at("SKETCH", "INSPECT") + .icon("draft_analysis") + .noundo() + .params("pull?: [x,y,z] direction (default Z); angle?: degrees (default 1); clear?: true turns the analysis off"), + CommandSpec::new("FusionCurvatureMapAnalysisCommand", "Curvature Map Analysis", curvature_map) + .at("SKETCH", "INSPECT") + .icon("curvature_map") + .noundo() + .params("radius?: mm shown red (default: a tenth of the model's size); clear?: true turns the analysis off"), +]; + +fn set(s: &mut Session, p: &Value, a: SurfaceAnalysis) -> Result { + if bool_(p, "clear").unwrap_or(false) { + s.analysis = None; + } else { + s.analysis = Some(a); + } + s.revision += 1; + Ok(json!({ "analysis": s.analysis })) +} + +fn zebra(s: &mut Session, p: &Value) -> Result { + let stripes = num(p, "stripes").unwrap_or(12.0); + if !(stripes.is_finite() && (1.0..=200.0).contains(&stripes)) { + return Err(bad("FusionZebraAnalysisCommand", "`stripes` must be 1…200")); + } + set(s, p, SurfaceAnalysis::Zebra { stripes }) +} + +fn draft(s: &mut Session, p: &Value) -> Result { + let cmd = "FusionDraftAnalysisCommand"; + let pull = match p.get("pull") { + Some(v) => vec3(v).and_then(Vec3::normalized).ok_or_else(|| bad(cmd, "`pull` must be a non-zero [x,y,z]"))?, + None => Vec3::Z, + }; + let angle = num(p, "angle").unwrap_or(1.0); + if !(angle.is_finite() && (0.0..90.0).contains(&angle)) { + return Err(bad(cmd, "`angle` must be 0…90 degrees")); + } + set(s, p, SurfaceAnalysis::Draft { pull, angle }) +} + +fn curvature_map(s: &mut Session, p: &Value) -> Result { + let cmd = "FusionCurvatureMapAnalysisCommand"; + let radius = match num(p, "radius") { + Some(r) if r.is_finite() && r > 1e-6 && r < 1e9 => r, + Some(_) => return Err(bad(cmd, "`radius` must be positive")), + None => { + let st = s.model.state(); + let size = st.bodies.iter().map(|b| b.mesh().bounds().diagonal()).fold(0.0_f64, f64::max); + if size > 0.0 { size / 10.0 } else { 10.0 } + } + }; + set(s, p, SurfaceAnalysis::Curvature { radius }) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::{Session, SurfaceAnalysis}; + + #[test] + fn surface_analyses_are_view_state() { + let mut s = Session::default(); + s.execute("PrimitiveBox", &json!({"length": 10, "width": 10, "height": 10})).unwrap(); + let undo = s.undo.len(); + s.execute("FusionZebraAnalysisCommand", &json!({"stripes": 20})).unwrap(); + assert_eq!(s.analysis, Some(SurfaceAnalysis::Zebra { stripes: 20.0 })); + s.execute("FusionDraftAnalysisCommand", &json!({"pull": [0, 0, 2], "angle": 3})).unwrap(); + assert!(matches!(s.analysis, Some(SurfaceAnalysis::Draft { angle, .. }) if angle == 3.0)); + s.execute("FusionCurvatureMapAnalysisCommand", &json!({})).unwrap(); + assert!(matches!(s.analysis, Some(SurfaceAnalysis::Curvature { radius }) if (radius - 300f64.sqrt() / 10.0).abs() < 1e-6)); + s.execute("FusionCurvatureMapAnalysisCommand", &json!({"clear": true})).unwrap(); + assert_eq!(s.analysis, None); + assert_eq!(s.undo.len(), undo, "no undo steps"); + assert!(s.execute("FusionZebraAnalysisCommand", &json!({"stripes": 0})).is_err()); + assert!(s.execute("FusionDraftAnalysisCommand", &json!({"pull": [0, 0, 0]})).is_err()); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 35ebf0ac..5e314838 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -162,6 +162,20 @@ pub struct Session { pub clipboard: Vec, /// Section Analysis: a view cut by a plane (origin, unit normal; the normal side is hidden). pub section: Option<(Vec3, Vec3)>, + /// Surface analysis shading the model (zebra, draft, curvature map). + pub analysis: Option, +} + +/// A way of shading the model's faces to judge their shape (view state, not part of the design). +#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum SurfaceAnalysis { + /// Reflected stripes: `stripes` across the view. + Zebra { stripes: f64 }, + /// Faces by draft angle to `pull`: past +`angle` green, past −`angle` red, between yellow. + Draft { pull: Vec3, angle: f64 }, + /// Colour by curvature: blue flat, through green, to red at 1 / `radius`. + Curvature { radius: f64 }, } const MAX_UNDO: usize = 200; @@ -195,6 +209,7 @@ impl Session { world_cache: Default::default(), clipboard: Vec::new(), section: None, + analysis: None, } } @@ -326,6 +341,7 @@ impl Session { world_cache: Default::default(), clipboard: self.clipboard.clone(), section: None, + analysis: None, } } diff --git a/crates/ui-egui/src/gpu.rs b/crates/ui-egui/src/gpu.rs index 67da7f20..679d517b 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 = 128; +const UNIFORM_SIZE: u64 = 160; /// 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. @@ -120,6 +120,9 @@ pub struct ViewportCallback { pub cap: [f32; 4], /// Canvases and decals. pub images: Vec, + /// Surface analysis: (mode: 0 none, 1 zebra, 2 draft, 3 curvature; parameter; 0; 0) and + /// the draft pull direction. + pub analysis: [f32; 8], } struct Batch { @@ -188,6 +191,8 @@ struct U { 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 + ana: vec4, // surface analysis: mode (1 zebra, 2 draft, 3 curvature), parameter + pull: vec4, // draft pull direction }; fn clipped(p: vec3) -> bool { @@ -230,9 +235,45 @@ fn fs_solid(i: TOut, @builtin(front_facing) front: bool) -> @location(0) vec4 0.5) { return out_color(u.cap); } + if (u.ana.x > 0.5 && length(i.n) > 0.5) { + return analysis(i); + } return shade(i); } +/// Surface analysis colours: zebra stripes, draft angle bands, curvature map. +fn analysis(i: TOut) -> vec4 { + let v = normalize(u.back.xyz); + var n = normalize(i.n); + if (dot(n, v) < 0.0) { n = -n; } + let lit = 0.55 + 0.45 * max(dot(n, v), 0.0); + if (u.ana.x < 1.5) { + // Reflections of upright light bars standing around the viewer: the stripe follows + // the reflected ray's sideways direction (horizontal on screen). + let r = reflect(-v, n); + var side = cross(v, vec3(0.0, 0.0, 1.0)); + if (length(side) < 1e-3) { side = vec3(1.0, 0.0, 0.0); } + let s = sin(dot(r, normalize(side)) * u.ana.y * 3.14159265); + let c = select(0.08, 0.95, s > 0.0); + return out_color(vec4(vec3(c), 1.0)); + } + if (u.ana.x < 2.5) { + let a = degrees(asin(clamp(dot(normalize(i.n), normalize(u.pull.xyz)), -1.0, 1.0))); + var c = vec3(0.95, 0.80, 0.15); + if (a >= u.ana.y) { c = vec3(0.20, 0.75, 0.30); } + if (a <= -u.ana.y) { c = vec3(0.85, 0.22, 0.20); } + return out_color(vec4(c * lit, 1.0)); + } + // Curvature from how fast the normal turns across the pixel. + let nn = normalize(i.n); + let kx = length(dpdx(nn)) / max(length(dpdx(i.wp)), 1e-6); + let ky = length(dpdy(nn)) / max(length(dpdy(i.wp)), 1e-6); + let t = clamp(max(kx, ky) * u.ana.y, 0.0, 1.0); + let c = select(mix(vec3(0.10, 0.80, 0.25), vec3(0.90, 0.15, 0.10), t * 2.0 - 1.0), + mix(vec3(0.15, 0.35, 0.95), vec3(0.10, 0.80, 0.25), t * 2.0), t < 0.5); + return out_color(vec4(c * lit, 1.0)); +} + @fragment fn fs_tri(i: TOut) -> @location(0) vec4 { if (clipped(i.wp)) { discard; } @@ -643,6 +684,7 @@ pub fn uniform_bytes(cb: &ViewportCallback, w: f32, h: f32, linear: bool) -> Vec v.extend([w.max(1.0), h.max(1.0), 2e-4, 0.0]); v.extend(cb.clip); v.extend(cb.cap); + v.extend(cb.analysis); v.iter().flat_map(|x| x.to_le_bytes()).collect() } diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 2238d588..bdd932db 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -652,6 +652,35 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce pen.line(&[(3.0 + 12.0 * t, 17.0 + 2.0 * t), (9.0 + 12.0 * t, 5.0 + 2.0 * t)], a); } } + "zebra" | "draft_analysis" | "curvature_map" => { + let pts: Vec = + (0..32).map(|i| i as f32 / 32.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 9.0 * t.cos(), 12.0 + 9.0 * t.sin())).collect(); + match name { + "zebra" => { + p.add(Shape::convex_polygon(pts, Color32::WHITE, Stroke::new(pen.w, ink))); + for k in 0..3 { + let y = 6.5 + k as f32 * 4.5; + let h = (81.0 - (y + 1.0 - 12.0) * (y + 1.0 - 12.0)).max(0.0).sqrt(); + pen.poly( + &[(12.0 - h, y), (12.0 + h, y), (12.0 + h, y + 2.2), (12.0 - h, y + 2.2)], + Color32::from_gray(30), + Color32::TRANSPARENT, + ); + } + pen.circle(12.0, 12.0, 9.0, Color32::TRANSPARENT, ink); + } + "draft_analysis" => { + pen.poly(&[(4.0, 20.0), (20.0, 20.0), (17.0, 5.0), (7.0, 5.0)], Color32::from_rgb(70, 180, 90), ink); + pen.poly(&[(7.0, 5.0), (17.0, 5.0), (16.0, 9.0), (8.0, 9.0)], Color32::from_rgb(235, 200, 50), Color32::TRANSPARENT); + pen.arrow((12.0, 16.0), (12.0, 1.5), a); + } + _ => { + p.add(Shape::convex_polygon(pts, Color32::from_rgb(60, 110, 230), Stroke::new(pen.w, ink))); + pen.circle(14.0, 10.0, 5.0, Color32::from_rgb(70, 190, 90), Color32::TRANSPARENT); + pen.circle(15.5, 8.5, 2.2, Color32::from_rgb(225, 60, 45), Color32::TRANSPARENT); + } + } + } "center_of_mass" => { pen.circle(12.0, 12.0, 8.0, Color32::TRANSPARENT, ink); let q = |a0: f32| { diff --git a/crates/ui-egui/src/sketch_tools.rs b/crates/ui-egui/src/sketch_tools.rs index 70dff402..321ba666 100644 --- a/crates/ui-egui/src/sketch_tools.rs +++ b/crates/ui-egui/src/sketch_tools.rs @@ -94,7 +94,23 @@ fn marks(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { /// Commands that start with a step of their own: Canvas and Decal ask for the image first, /// Edit Canvas opens the canvas panel. False when the command should not go on. -pub fn start_hook(app: &SolveApp, id: &str) -> bool { +pub fn start_hook(app: &mut SolveApp, id: &str) -> bool { + // Surface analyses toggle: a second click turns the same one off. + use solvecraft_engine::SurfaceAnalysis as A; + let same = match (id, app.session.analysis) { + ("FusionZebraAnalysisCommand", Some(A::Zebra { .. })) => Some(true), + ("FusionDraftAnalysisCommand", Some(A::Draft { .. })) => Some(true), + ("FusionCurvatureMapAnalysisCommand", Some(A::Curvature { .. })) => Some(true), + ("FusionZebraAnalysisCommand" | "FusionDraftAnalysisCommand" | "FusionCurvatureMapAnalysisCommand", _) => Some(false), + _ => None, + }; + if let Some(on) = same { + let p = if on { json!({"clear": true}) } else { json!({}) }; + if app.run(id, p).is_ok() { + app.set_status(if on { "Analysis off" } else { "Analysis on (click it again to turn it off)" }, false); + } + return false; + } if id == "FusionCenterOfMassCommand" { // The whole model at once; the tool stays for picking single bodies. MARKS.with(|m| m.borrow_mut().clear()); diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index fcf50fb6..c1acc2f3 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -646,6 +646,17 @@ pub fn section_plane(app: &SolveApp) -> Option<(Vec3, Vec3)> { } /// The section as the shader's clip plane (normal, d), zero when there is none. +/// The surface analysis for the shader (see `gpu::ViewportCallback::analysis`). +fn analysis_uniform(app: &SolveApp) -> [f32; 8] { + use solvecraft_engine::SurfaceAnalysis as A; + match app.session.analysis { + Some(A::Zebra { stripes }) => [1.0, stripes as f32, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0], + Some(A::Draft { pull, angle }) => [2.0, angle as f32, 0.0, 0.0, pull.x as f32, pull.y as f32, pull.z as f32, 0.0], + Some(A::Curvature { radius }) => [3.0, radius as f32, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0], + None => [0.0; 8], + } +} + 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], @@ -1126,6 +1137,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { back: proj.cam.back().to_f32(), size_px: [rect.width() * ppp, rect.height() * ppp], images: crate::ref_images::gpu_images(app), + analysis: analysis_uniform(app), }; painter.add(egui_wgpu::Callback::new_paint_callback(rect, cb)); } else {