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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 09:42:34 +00:00
1 parent 319386760f
commit dea1f46266
7 files changed
+216 -2

No files matched your search

+2
View File
@@ -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());
+97
View File
@@ -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<Value> {
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<Value> {
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<Value> {
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<Value> {
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());
}
}
+16
View File
@@ -162,6 +162,20 @@ pub struct Session {
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)>,
/// Surface analysis shading the model (zebra, draft, curvature map).
pub analysis: Option<SurfaceAnalysis>,
}
/// 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,
}
}
+43 -1
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 = 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<GpuImage>,
/// 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<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
ana: vec4<f32>, // surface analysis: mode (1 zebra, 2 draft, 3 curvature), parameter
pull: vec4<f32>, // draft pull direction
};
fn clipped(p: vec3<f32>) -> bool {
@@ -230,9 +235,45 @@ fn fs_solid(i: TOut, @builtin(front_facing) front: bool) -> @location(0) vec4<f3
if (!front && length(u.clip.xyz) > 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<f32> {
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<f32>(0.0, 0.0, 1.0));
if (length(side) < 1e-3) { side = vec3<f32>(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<f32>(vec3<f32>(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<f32>(0.95, 0.80, 0.15);
if (a >= u.ana.y) { c = vec3<f32>(0.20, 0.75, 0.30); }
if (a <= -u.ana.y) { c = vec3<f32>(0.85, 0.22, 0.20); }
return out_color(vec4<f32>(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<f32>(0.10, 0.80, 0.25), vec3<f32>(0.90, 0.15, 0.10), t * 2.0 - 1.0),
mix(vec3<f32>(0.15, 0.35, 0.95), vec3<f32>(0.10, 0.80, 0.25), t * 2.0), t < 0.5);
return out_color(vec4<f32>(c * lit, 1.0));
}
@fragment
fn fs_tri(i: TOut) -> @location(0) vec4<f32> {
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()
}
+29
View File
@@ -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<Pos2> =
(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| {
+17 -1
View File
@@ -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());
+12
View File
@@ -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 {