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:
1 parent
319386760f
commit
dea1f46266
7 files changed
+216
-2
No files matched your search
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user