From 940c4253160e3ed6df6fb23960f794edc0b095ac Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 10:09:35 +0000 Subject: [PATCH] Accessibility Analysis: faces a tool coming along a direction reaches (facing it, nothing above: a depth map of the model seen from that direction, tested per pixel on the GPU) shade green, the rest red; the analyses (zebra, draft, curvature map, environment map, accessibility, minimum radius, isocurves, curvature comb, center of mass) also on SOLID INSPECT, and Interference on sketch INSPECT; path patterns follow 3D sketch curves through the same path chain as sweeps (path_segments_3d folded into path_points) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/doc/src/eval.rs | 62 +--------- crates/engine/src/cmd/surface_analysis.rs | 17 +++ crates/engine/src/lib.rs | 3 + crates/ui-egui/src/access.rs | 106 +++++++++++++++++ crates/ui-egui/src/gpu.rs | 138 +++++++++++++++++++++- crates/ui-egui/src/icons.rs | 7 ++ crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/sketch_tools.rs | 41 ++++++- crates/ui-egui/src/toolbar.rs | 1 + crates/ui-egui/src/viewport.rs | 5 + 10 files changed, 316 insertions(+), 65 deletions(-) create mode 100644 crates/ui-egui/src/access.rs diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index d5db0d5d..2763301d 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -1181,8 +1181,8 @@ fn path_segments(ps: &SolvedSketch, ids: &[String], start: Vec3) -> Result = Vec::new(); for id in ids { @@ -1230,64 +1230,6 @@ fn path_segments(ps: &SolvedSketch, ids: &[String], start: Vec3) -> Result Result> { - let mut pieces: Vec> = Vec::new(); - for id in ids { - let pts: Vec = if let Some(ci) = ps.sketch.curve_index(id) { - let mut v: Vec = Vec::new(); - for s in ps.sketch.segs(ci) { - for q in s.polyline(1e-2) { - let w = ps.plane.to_world(q); - if v.last().is_none_or(|l| l.dist(w) > 1e-12) { - v.push(w); - } - } - } - v - } else { - let w = ps.sketch.wire_index(id).and_then(|i| ps.sketch.wires.get(i)).ok_or_else(|| DocError::Unknown(format!("path curve `{id}`")))?; - w.pts.clone() - }; - if pts.len() < 2 { - return Err(DocError::Invalid(format!("`{id}` is not a curve"))); - } - pieces.push(pts); - } - let ends = |p: &Vec| (p.first().copied().unwrap_or_default(), p.last().copied().unwrap_or_default()); - let tol = 1e-5; - let flip_first = match (pieces.first().map(ends), pieces.get(1).map(ends)) { - (Some((a, b)), _) if start.is_finite() => b.dist(start) < a.dist(start), - (Some((a, _)), Some((c, d))) => c.dist(a) < tol || d.dist(a) < tol, - _ => false, - }; - if flip_first && let Some(f) = pieces.first_mut() { - f.reverse(); - } - for i in 1..pieces.len() { - let prev_end = pieces.get(i - 1).map(|p| ends(p).1).unwrap_or_default(); - if let Some(p) = pieces.get_mut(i) { - let (a, b) = ends(p); - if b.dist(prev_end) < a.dist(prev_end) { - p.reverse(); - } - if ends(p).0.dist(prev_end) > tol { - return Err(DocError::Invalid("the path curves are not connected end to end".into())); - } - } - } - let segs: Vec = pieces - .iter() - .flat_map(|p| p.windows(2).filter(|w| w[0].dist(w[1]) > 1e-9).map(|w| kernel::PathSeg::Line { a: w[0], b: w[1] }).collect::>()) - .collect(); - if segs.len() > 1000 { - return Err(DocError::Invalid("the path is too detailed (over 1000 segments)".into())); - } - Ok(segs) -} - /// Operation and targets of a feature that makes tools. fn feature_op(f: &Feature) -> (Operation, Vec) { match &f.kind { diff --git a/crates/engine/src/cmd/surface_analysis.rs b/crates/engine/src/cmd/surface_analysis.rs index 67c81c65..513147a7 100644 --- a/crates/engine/src/cmd/surface_analysis.rs +++ b/crates/engine/src/cmd/surface_analysis.rs @@ -24,6 +24,11 @@ pub static COMMANDS: &[CommandSpec] = &[ .icon("environment_map") .noundo() .params("clear?: true turns the analysis off"), + CommandSpec::new("FusionAccessibilityAnalysisCommand", "Accessibility Analysis", accessibility) + .at("SKETCH", "INSPECT") + .icon("accessibility") + .noundo() + .params("direction?: [x,y,z] the tool comes from (default Z, from above); clear?: true turns the analysis off"), CommandSpec::new("FusionCurvatureMapAnalysisCommand", "Curvature Map Analysis", curvature_map) .at("SKETCH", "INSPECT") .icon("curvature_map") @@ -62,6 +67,16 @@ fn draft(s: &mut Session, p: &Value) -> Result { set(s, p, SurfaceAnalysis::Draft { pull, angle }) } +fn accessibility(s: &mut Session, p: &Value) -> Result { + let dir = match p.get("direction") { + Some(v) => { + vec3(v).and_then(Vec3::normalized).ok_or_else(|| bad("FusionAccessibilityAnalysisCommand", "`direction` must be a non-zero [x,y,z]"))? + } + None => Vec3::Z, + }; + set(s, p, SurfaceAnalysis::Access { dir }) +} + fn curvature_map(s: &mut Session, p: &Value) -> Result { let cmd = "FusionCurvatureMapAnalysisCommand"; let radius = match num(p, "radius") { @@ -95,6 +110,8 @@ mod tests { assert!(matches!(s.analysis, Some(SurfaceAnalysis::Curvature { radius }) if (radius - 300f64.sqrt() / 10.0).abs() < 1e-6)); s.execute("FusionEnvironmentMapAnalysisCommand", &json!({})).unwrap(); assert_eq!(s.analysis, Some(SurfaceAnalysis::Environment)); + s.execute("FusionAccessibilityAnalysisCommand", &json!({"direction": [0, 0, -3]})).unwrap(); + assert_eq!(s.analysis, Some(SurfaceAnalysis::Access { dir: solvecraft_geom::Vec3::new(0.0, 0.0, -1.0) })); s.execute("FusionCurvatureMapAnalysisCommand", &json!({"clear": true})).unwrap(); assert_eq!(s.analysis, None); assert_eq!(s.undo.len(), undo, "no undo steps"); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a1d08fe1..85373f83 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -178,6 +178,9 @@ pub enum SurfaceAnalysis { Curvature { radius: f64 }, /// Mirror finish reflecting a studio (sky, horizon, floor, light panels). Environment, + /// Green where a tool coming along `dir` reaches the face (facing it, nothing above), red + /// elsewhere. + Access { dir: Vec3 }, } const MAX_UNDO: usize = 200; diff --git a/crates/ui-egui/src/access.rs b/crates/ui-egui/src/access.rs new file mode 100644 index 00000000..5fe61cad --- /dev/null +++ b/crates/ui-egui/src/access.rs @@ -0,0 +1,106 @@ +//! Accessibility Analysis: the model's height map seen from the access direction, for the GPU +//! to tell reachable surface from surface under something else. + +use std::cell::RefCell; +use std::sync::Arc; + +use solvecraft_engine::SurfaceAnalysis; +use solvecraft_engine::geom::Vec3; + +use crate::SolveApp; +use crate::gpu::AccessMap; + +/// Map resolution (pixels per side). +const SIZE: u32 = 1024; + +thread_local! { + /// The last map, by (model revision, direction). + static CACHE: RefCell> = const { RefCell::new(None) }; + static VERSION: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// The depth map for the active Accessibility Analysis, if any. +pub fn map(app: &SolveApp) -> Option { + let Some(SurfaceAnalysis::Access { dir }) = app.session.analysis else { return None }; + let key = (app.session.revision, [dir.x.to_bits(), dir.y.to_bits(), dir.z.to_bits()]); + if let Some(m) = CACHE.with(|c| c.borrow().as_ref().filter(|(k, _)| *k == key).map(|(_, m)| m.clone())) { + return Some(m); + } + let st = app.session.world_state(); + let meshes: Vec<_> = st.bodies.iter().filter(|b| !app.ui.hidden_bodies.contains(&b.name)).map(|b| b.mesh()).collect(); + // Every new map gets a new version (the GPU uploads it once). + let version = VERSION.with(|v| { + v.set(v.get() + 1); + v.get() + }); + let m = build(&meshes.iter().map(|m| m.as_ref()).collect::>(), dir, version)?; + CACHE.with(|c| *c.borrow_mut() = Some((key, m.clone()))); + Some(m) +} + +/// Rasterise the triangles seen along `dir` (orthographic), keeping the highest point per pixel. +fn build(meshes: &[&solvecraft_engine::geom::Mesh], dir: Vec3, version: u64) -> Option { + let d = dir.normalized()?; + let u = d.cross(if d.x.abs() < 0.9 { Vec3::X } else { Vec3::Y }).normalized()?; + let v = d.cross(u); + let (mut lo, mut hi) = ([f64::INFINITY; 2], [f64::NEG_INFINITY; 2]); + for m in meshes { + for p in &m.positions { + let (a, b) = (p.dot(u), p.dot(v)); + lo = [lo[0].min(a), lo[1].min(b)]; + hi = [hi[0].max(a), hi[1].max(b)]; + } + } + let ext = (hi[0] - lo[0]).max(hi[1] - lo[1]); + if !(ext.is_finite() && ext > 1e-9) { + return None; + } + let n = SIZE as usize; + let scale = (SIZE - 2) as f64 / ext; + // One pixel of margin on each side. + let (ou, ov) = (-lo[0] + 1.0 / scale, -lo[1] + 1.0 / scale); + let mut depth = vec![f32::NEG_INFINITY; n * n]; + for m in meshes { + for t in &m.triangles { + let q: Vec<(f64, f64, f64)> = + t.iter().filter_map(|i| m.positions.get(*i as usize)).map(|p| ((p.dot(u) + ou) * scale, (p.dot(v) + ov) * scale, p.dot(d))).collect(); + let [a, b, c] = q[..] else { continue }; + let area = (b.0 - a.0) * (c.1 - a.1) - (b.1 - a.1) * (c.0 - a.0); + let (x0, x1) = (a.0.min(b.0).min(c.0).floor().max(0.0) as usize, (a.0.max(b.0).max(c.0).ceil() as usize).min(n - 1)); + let (y0, y1) = (a.1.min(b.1).min(c.1).floor().max(0.0) as usize, (a.1.max(b.1).max(c.1).ceil() as usize).min(n - 1)); + // Edge-on triangles: their vertices still count. + if area.abs() < 1e-12 { + for p in [a, b, c] { + let (x, y) = (p.0 as usize, p.1 as usize); + if let Some(z) = depth.get_mut(y * n + x) { + *z = z.max(p.2 as f32); + } + } + continue; + } + for y in y0..=y1 { + for x in x0..=x1 { + let (px, py) = (x as f64 + 0.5, y as f64 + 0.5); + let w0 = ((b.0 - px) * (c.1 - py) - (b.1 - py) * (c.0 - px)) / area; + let w1 = ((c.0 - px) * (a.1 - py) - (c.1 - py) * (a.0 - px)) / area; + let w2 = 1.0 - w0 - w1; + if w0 < -1e-9 || w1 < -1e-9 || w2 < -1e-9 { + continue; + } + let z = (w0 * a.2 + w1 * b.2 + w2 * c.2) as f32; + if let Some(cell) = depth.get_mut(y * n + x) { + *cell = cell.max(z); + } + } + } + } + } + Some(AccessMap { + version, + size: SIZE, + depth: Arc::new(depth), + u: [u.x as f32, u.y as f32, u.z as f32, ou as f32], + v: [v.x as f32, v.y as f32, v.z as f32, ov as f32], + scale: scale as f32, + }) +} diff --git a/crates/ui-egui/src/gpu.rs b/crates/ui-egui/src/gpu.rs index 960c6e8b..7300190b 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 = 160; +const UNIFORM_SIZE: u64 = 192; /// 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,9 +120,25 @@ 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. + /// Surface analysis: (mode: 0 none, 1 zebra, 2 draft, 3 curvature, 4 environment, + /// 5 accessibility; parameter; scale; size) and the pull / access direction. pub analysis: [f32; 8], + /// Accessibility: the depth map seen from the access direction. + pub access: Option, +} + +/// Heights of the model seen from a direction (an orthographic depth map): a point is reachable +/// from that direction when nothing lies above it. +#[derive(Clone)] +pub struct AccessMap { + pub version: u64, + pub size: u32, + /// Row-major heights along the direction (−∞ where nothing is). + pub depth: Arc>, + /// Map axes: pixel = (p·u + u.w, p·v + v.w) × scale. + pub u: [f32; 4], + pub v: [f32; 4], + pub scale: f32, } struct Batch { @@ -182,6 +198,41 @@ struct Resources { textures: std::collections::HashMap, /// This frame's quads: (image id, vertices). quads: Vec<(u64, Batch)>, + access_bgl: wgpu::BindGroupLayout, + /// The accessibility depth map (a 1 × 1 stand-in when there is none) and its version. + access_bind: wgpu::BindGroup, + access_version: u64, +} + +fn access_texture(device: &wgpu::Device, queue: Option<&wgpu::Queue>, bgl: &wgpu::BindGroupLayout, size: u32, depth: &[f32]) -> wgpu::BindGroup { + let extent = wgpu::Extent3d { width: size.max(1), height: size.max(1), depth_or_array_layers: 1 }; + let tex = device.create_texture(&wgpu::TextureDescriptor { + label: Some("sc_access"), + size: extent, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::R32Float, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + if let Some(q) = queue + && depth.len() == (size as usize) * (size as usize) + { + let bytes: Vec = depth.iter().flat_map(|x| x.to_le_bytes()).collect(); + q.write_texture( + wgpu::TexelCopyTextureInfo { texture: &tex, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, + &bytes, + wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(4 * size), rows_per_image: Some(size) }, + extent, + ); + } + let view = tex.create_view(&Default::default()); + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("sc_access"), + layout: bgl, + entries: &[wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(&view) }], + }) } const SHADER: &str = r#" @@ -193,8 +244,12 @@ struct U { 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 + acc_u: vec4, // accessibility map axes (xyz, offset) + acc_v: vec4, }; +@group(1) @binding(2) var acc_t: texture_2d; + fn clipped(p: vec3) -> bool { return length(u.clip.xyz) > 0.5 && dot(u.clip.xyz, p) > u.clip.w; } @@ -257,6 +312,24 @@ fn analysis(i: TOut) -> vec4 { let c = select(0.08, 0.95, s > 0.0); return out_color(vec4(vec3(c), 1.0)); } + if (u.ana.x > 4.5) { + // Reachable from the access direction: facing it and nothing above. + let d = normalize(u.pull.xyz); + let size = i32(u.ana.w); + let px = vec2(floor(vec2(dot(i.wp, u.acc_u.xyz) + u.acc_u.w, dot(i.wp, u.acc_v.xyz) + u.acc_v.w) * u.ana.z)); + var top = -1e30; + if (px.x >= 0 && px.y >= 0 && px.x < size && px.y < size) { + top = textureLoad(acc_t, px, 0).x; + } + let cn = dot(normalize(i.n), d); + let facing = cn > 0.02; + // Slack of a pixel and a half, more on slopes (the map holds the pixel's highest point). + let slack = u.ana.y * (1.0 + sqrt(max(1.0 - cn * cn, 0.0)) / max(cn, 0.05)); + let clear = dot(i.wp, d) >= top - slack; + var c = vec3(0.85, 0.22, 0.20); + if (facing && clear) { c = vec3(0.20, 0.75, 0.30); } + return out_color(vec4(c * lit, 1.0)); + } if (u.ana.x > 3.5) { // A studio around the model: sky above, a dark floor below, a bright horizon band and // two soft light panels, seen in a mirror. @@ -528,6 +601,46 @@ impl Resources { multiview_mask: None, cache: None, }); + let access_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("sc_access"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }], + }); + let solid_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("sc_solid"), + bind_group_layouts: &[Some(&bgl), Some(&access_bgl)], + immediate_size: 0, + }); + let solid = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("sc_tris"), + layout: Some(&solid_layout), + vertex: wgpu::VertexState { + module: &module, + entry_point: Some("vs_tri"), + buffers: &[Some(tri_layout())], + compilation_options: Default::default(), + }, + primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() }, + depth_stencil: depth(wgpu::CompareFunction::Less, true), + multisample: wgpu::MultisampleState { count: t.samples.max(1), mask: !0, alpha_to_coverage_enabled: false }, + fragment: Some(wgpu::FragmentState { + module: &module, + entry_point: Some("fs_solid"), + targets: &[Some(wgpu::ColorTargetState { format: t.format, blend: None, write_mask: wgpu::ColorWrites::ALL })], + compilation_options: Default::default(), + }), + multiview_mask: None, + cache: None, + }); + let access_bind = access_texture(device, None, &access_bgl, 1, &[]); let sampler = device.create_sampler(&wgpu::SamplerDescriptor { label: Some("sc_image"), mag_filter: wgpu::FilterMode::Linear, @@ -540,7 +653,10 @@ impl Resources { sampler, textures: Default::default(), quads: Vec::new(), - tri: pipeline("sc_tris", "vs_tri", "fs_solid", tri_layout(), depth(wgpu::CompareFunction::Less, true), None), + tri: solid, + access_bgl, + access_bind, + access_version: 0, 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), @@ -593,6 +709,12 @@ impl egui_wgpu::CallbackTrait for ViewportCallback { res.highlight.take(device, self.hl_key, &self.hl_slot); res.preview.take(device, self.pv_key, &self.pv_slot); queue.write_buffer(&res.uniform, 0, &uniform_bytes(self, self.size_px[0], self.size_px[1], res.linear_out)); + if let Some(a) = &self.access + && a.version != res.access_version + { + res.access_bind = access_texture(device, Some(queue), &res.access_bgl, a.size, &a.depth); + res.access_version = a.version; + } // Images: upload new or changed textures, drop removed ones, rebuild the quads. res.textures.retain(|id, _| self.images.iter().any(|i| i.id == *id)); res.quads.clear(); @@ -669,6 +791,7 @@ impl egui_wgpu::CallbackTrait for ViewportCallback { }; let (m, h, pv) = (&res.model, &res.highlight, &res.preview); lines(pass, &res.overlay, &m.under); + pass.set_bind_group(1, &res.access_bind, &[]); tris(pass, &res.tri, &m.tris); tris(pass, &res.tri, &pv.tris); tris(pass, &res.tri_hl, &h.tris); @@ -700,6 +823,13 @@ pub fn uniform_bytes(cb: &ViewportCallback, w: f32, h: f32, linear: bool) -> Vec v.extend(cb.clip); v.extend(cb.cap); v.extend(cb.analysis); + match &cb.access { + Some(a) => { + v.extend(a.u); + v.extend(a.v); + } + None => v.extend([0.0; 8]), + } 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 aa3368c8..c2efe59f 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -652,6 +652,13 @@ 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); } } + "accessibility" => { + pen.poly(&[(2.0, 20.0), (22.0, 20.0), (22.0, 15.0), (2.0, 15.0)], Color32::from_rgb(70, 180, 90), ink); + pen.poly(&[(8.0, 15.0), (16.0, 15.0), (16.0, 17.5), (8.0, 17.5)], Color32::from_rgb(220, 70, 60), Color32::TRANSPARENT); + pen.poly(&[(6.0, 10.0), (18.0, 10.0), (18.0, 12.5), (6.0, 12.5)], pen.fill, ink); + pen.arrow((4.0, 2.0), (4.0, 13.0), a); + pen.arrow((20.0, 2.0), (20.0, 13.0), a); + } "environment_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(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4539f7d2..2a8bb08d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -8,6 +8,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +pub mod access; pub mod browser; pub mod canvas; pub mod context_menu; diff --git a/crates/ui-egui/src/sketch_tools.rs b/crates/ui-egui/src/sketch_tools.rs index 6e5e6567..b4c74c89 100644 --- a/crates/ui-egui/src/sketch_tools.rs +++ b/crates/ui-egui/src/sketch_tools.rs @@ -40,6 +40,40 @@ thread_local! { static OPACITY: Cell> = const { Cell::new(None) }; } +/// Commands registered on one panel that Fusion also lists on another: the analyses (sketch +/// INSPECT) on SOLID INSPECT, and Interference (SOLID INSPECT) on sketch INSPECT. +const ALSO_IN: &[(&str, &str, &str)] = &[ + ("SOLID", "INSPECT", "FusionZebraAnalysisCommand"), + ("SOLID", "INSPECT", "FusionDraftAnalysisCommand"), + ("SOLID", "INSPECT", "FusionCurvatureMapAnalysisCommand"), + ("SOLID", "INSPECT", "FusionEnvironmentMapAnalysisCommand"), + ("SOLID", "INSPECT", "FusionAccessibilityAnalysisCommand"), + ("SOLID", "INSPECT", "FusionMinimumRadiusAnalysisCommand"), + ("SOLID", "INSPECT", "FusionIsoCurveAnalysisCommand"), + ("SOLID", "INSPECT", "FusionCurvatureCombAnalysisCommand"), + ("SOLID", "INSPECT", "FusionCenterOfMassCommand"), + ("SKETCH", "INSPECT", "InterferenceCheckCommand"), +]; + +/// A panel's commands plus the ones it shares with another panel (see [`ALSO_IN`]). +pub fn also_in<'a>( + tab: &str, + panel: &str, + mut cmds: Vec<&'a solvecraft_engine::CommandSpec>, + specs: &[&'a solvecraft_engine::CommandSpec], +) -> Vec<&'a solvecraft_engine::CommandSpec> { + for (t, p, id) in ALSO_IN { + if *t == tab + && *p == panel + && !cmds.iter().any(|c| c.id == *id) + && let Some(c) = specs.iter().find(|c| c.id == *id) + { + cmds.push(c); + } + } + cmds +} + /// Run an analysis and keep its result as a marker. fn analysis(app: &mut SolveApp, cmd: &str, p: Value) { let Ok(v) = app.run(cmd, p) else { return }; @@ -102,8 +136,13 @@ pub fn start_hook(app: &mut SolveApp, id: &str) -> bool { ("FusionDraftAnalysisCommand", Some(A::Draft { .. })) => Some(true), ("FusionCurvatureMapAnalysisCommand", Some(A::Curvature { .. })) => Some(true), ("FusionEnvironmentMapAnalysisCommand", Some(A::Environment)) => Some(true), + ("FusionAccessibilityAnalysisCommand", Some(A::Access { .. })) => Some(true), ( - "FusionZebraAnalysisCommand" | "FusionDraftAnalysisCommand" | "FusionCurvatureMapAnalysisCommand" | "FusionEnvironmentMapAnalysisCommand", + "FusionZebraAnalysisCommand" + | "FusionDraftAnalysisCommand" + | "FusionCurvatureMapAnalysisCommand" + | "FusionEnvironmentMapAnalysisCommand" + | "FusionAccessibilityAnalysisCommand", _, ) => Some(false), _ => None, diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 9d362e8e..471abf9e 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -272,6 +272,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { Some(l) => l, None => (specs.iter().filter(|c| c.tab == tab && c.panel == *panel).copied().collect(), promoted(&tab, panel)), }; + let cmds = crate::sketch_tools::also_in(&tab, panel, cmds, &specs); let n = promote.min(cmds.len()).max(if cmds.is_empty() { 1 } else { 0 }); let width = (n as f32 * 40.0).max(64.0) + 8.0; let enabled_panel = !cmds.is_empty() || *panel == "SELECT"; diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index a7f9d5d2..d92a5d7a 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -654,6 +654,10 @@ fn analysis_uniform(app: &SolveApp) -> [f32; 8] { 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], Some(A::Environment) => [4.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0], + Some(A::Access { dir }) => { + let (scale, size, eps) = crate::access::map(app).map(|m| (m.scale, m.size as f32, 1.5 / m.scale.max(1e-9))).unwrap_or((0.0, 0.0, 0.0)); + [5.0, eps, scale, size, dir.x as f32, dir.y as f32, dir.z as f32, 0.0] + } None => [0.0; 8], } } @@ -1139,6 +1143,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { size_px: [rect.width() * ppp, rect.height() * ppp], images: crate::ref_images::gpu_images(app), analysis: analysis_uniform(app), + access: crate::access::map(app), }; painter.add(egui_wgpu::Callback::new_paint_callback(rect, cb)); } else {