diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index d7b64cba..17ddd7f5 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -31,6 +31,9 @@ pub struct ModelBody { /// The display plan (none when the body has no plan: a mesh body, or one the planner /// refuses), shared like the mesh. A GPU viewport draws it. plan: Arc>>>, + /// The plan evaluated on the CPU (none without a plan), shared like the mesh: the triangles a + /// GPU viewport draws, so a highlight over a face lies exactly on it. + plan_mesh: Arc>>>, /// Identity of this body's content for the evaluation cache: the key of the evaluation /// that produced it (kept while later features pass it through unchanged). key: u64, @@ -59,6 +62,7 @@ impl ModelBody { feature, mesh: Arc::new(OnceLock::new()), plan: Arc::new(OnceLock::new()), + plan_mesh: Arc::new(OnceLock::new()), key: 0, names: Default::default(), made: Default::default(), @@ -98,6 +102,14 @@ impl ModelBody { .clone() } + /// The display plan's mesh, evaluated on the CPU once: the same triangles a GPU viewport + /// evaluates and draws for this body. Highlights over a face drawn from the plan use it, since + /// the reference mesh ([`ModelBody::mesh`]) triangulates the face differently and the two + /// would fight at equal depth. `None` when the body has no plan. + pub fn plan_mesh(&self) -> Option> { + self.plan_mesh.get_or_init(|| self.display_plan().and_then(|plan| self.body.display_mesh_of(&plan).ok()).map(Arc::new)).clone() + } + /// The display mesh's chord tolerance: 1/1000 of the body's size. fn display_tol(&self) -> f64 { (self.body.size() * 1e-3).max(1e-3) diff --git a/crates/ui-egui/src/gpu.rs b/crates/ui-egui/src/gpu.rs index af854ea0..0578ac16 100644 --- a/crates/ui-egui/src/gpu.rs +++ b/crates/ui-egui/src/gpu.rs @@ -642,7 +642,7 @@ impl Resources { 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), + tri_hl: pipeline("sc_tris_hl", "vs_tri", "fs_tri", tri_layout(), highlight_depth(t.depth), alpha), trans: pipeline("sc_trans", "vs_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha), glass_far: glass("sc_glass_far", wgpu::Face::Front), glass_near: glass("sc_glass_near", wgpu::Face::Back), @@ -662,6 +662,24 @@ impl Resources { } } +/// Depth state of faces lit over the model (hover, selection): tested, not written, and pulled a +/// little toward the eye. They lie on the model's own faces, but the GPU evaluates a body's plan +/// in f32 while the highlight's copy of the same triangles is evaluated on the CPU, so the two +/// depths differ by rounding and an unbiased equal test lights the face in speckles and wedges. +/// The bias is a few depth steps plus the face's own slope: far less than any real gap. +fn highlight_depth(format: Option) -> Option { + format.map(|format| wgpu::DepthStencilState { + format, + depth_write_enabled: Some(false), + depth_compare: Some(wgpu::CompareFunction::LessEqual), + stencil: Default::default(), + bias: HIGHLIGHT_BIAS, + }) +} + +/// See [`highlight_depth`]. Negative: toward the eye (depth grows away from it). +const HIGHLIGHT_BIAS: wgpu::DepthBiasState = wgpu::DepthBiasState { constant: -8, slope_scale: -1.0, clamp: 0.0 }; + fn upload(device: &wgpu::Device, label: &str, bytes: &[u8], stride: usize) -> Option { upload_with(device, label, bytes, stride, wgpu::BufferUsages::VERTEX) } @@ -895,8 +913,17 @@ impl egui_wgpu::CallbackTrait for ViewportCallback { /// Render `scene` offscreen on a device of its own, through the viewport's real resources and /// frame callback (`prepare`, then `paint`), and read the pixels back: straight RGBA, `size` × /// `size`. `None` without a GPU adapter. With `tess`, planned bodies are evaluated on the GPU. +/// `highlight` is drawn as the viewport's highlight scene (hover and selection). #[cfg(all(test, not(target_arch = "wasm32")))] -pub(crate) fn render_offscreen(scene: GpuScene, view_proj: [[f32; 4]; 4], back: [f32; 3], eye: [f32; 4], size: u32, tess: bool) -> Option> { +pub(crate) fn render_offscreen( + scene: GpuScene, + highlight: Option, + view_proj: [[f32; 4]; 4], + back: [f32; 3], + eye: [f32; 4], + size: u32, + tess: bool, +) -> Option> { let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?; let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { @@ -915,8 +942,8 @@ pub(crate) fn render_offscreen(scene: GpuScene, view_proj: [[f32; 4]; 4], back: slot: Arc::new(Mutex::new(Some(scene))), sketch_key: 0, sketch_slot: none(), - hl_key: 0, - hl_slot: none(), + hl_key: u64::from(highlight.is_some()), + hl_slot: Arc::new(Mutex::new(highlight)), pv_key: 0, pv_slot: none(), view_proj, diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 4fe90b95..f5ca73ba 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -305,6 +305,51 @@ fn sketch_color(t: &Tokens, c: Rgb) -> [u8; 4] { } } +/// With a component active, bodies outside it are drawn faded. +fn body_faded(app: &SolveApp, b: &solidcraft_engine::doc::ModelBody, active_bodies: &Option>) -> bool { + let s = &app.session; + if let Some(names) = active_bodies { + !names.contains(&b.name) + } else { + s.active_component != 0 && !s.doc.component_within(b.component, s.active_component) + } +} + +/// The display plan the model scene draws `b` from on the GPU, or `None` when its faces are built +/// here from the reference mesh. Opaque shaded bodies (`opaque`: not faded, and no see-through +/// body or face) are drawn from their plans where the device evaluates them; faded and +/// see-through ones (sorted and blended per triangle) are built here. +fn drawn_plan( + app: &SolveApp, + b: &solidcraft_engine::doc::ModelBody, + opaque: bool, +) -> Option> { + let shaded = app.ui.visual_style != 2; + (shaded && opaque && app.viewport.gpu.is_some_and(|g| g.tess)) + .then(|| b.display_plan()) + .flatten() + .filter(|plan| plan.vertex_count() <= solidcraft_gpu::MAX_BATCH) +} + +/// The mesh whose triangles the model scene shows for `b`'s faces: the plan's (evaluated on the +/// CPU, the same triangles the GPU draws) or the reference mesh. A face highlighted over the model +/// must use the same triangles: another triangulation of the same face fights it at equal depth +/// and shimmers. +fn shown_mesh( + app: &SolveApp, + b: &solidcraft_engine::doc::ModelBody, + active_bodies: &Option>, +) -> std::sync::Arc { + let doc = &app.session.doc; + let opaque = + || !body_faded(app, b, active_bodies) && doc.model_body_opacity(b) >= 0.999 && doc.face_colors(b).iter().all(|(_, l)| l.opacity >= 0.999); + (app.viewport.gpu.is_some_and(|g| g.tess) && opaque()) + .then(|| drawn_plan(app, b, true)) + .flatten() + .and_then(|_| b.plan_mesh()) + .unwrap_or_else(|| b.mesh()) +} + fn build_scene(app: &SolveApp) -> GpuScene { let tk = Tokens::get(); let mut sc = GpuScene::default(); @@ -316,12 +361,7 @@ fn build_scene(app: &SolveApp) -> GpuScene { if !app.session.body_visible(&b.name) || app.preview.replaced.contains(&b.name) { continue; } - // With a component active, bodies outside it are drawn faded. - let faded = if let Some(names) = &active_bodies { - !names.contains(&b.name) - } else { - s.active_component != 0 && !s.doc.component_within(b.component, s.active_component) - }; + let faded = body_faded(app, b, &active_bodies); let base = body_color(&s.doc, b).unwrap_or(tk.body); let col = if faded { c4(base.gamma_multiply(0.35)) } else { c4(base) }; let m = b.mesh(); @@ -338,13 +378,8 @@ fn build_scene(app: &SolveApp) -> GpuScene { let opacity = s.doc.model_body_opacity(b); let face_looks: std::collections::HashMap = if shaded { s.doc.face_colors(b).into_iter().collect() } else { Default::default() }; - // Opaque bodies are drawn from their display plans on the GPU where the device evaluates - // them; faded and see-through ones (sorted and blended per triangle) are built here. let opaque = !faded && opacity >= 0.999 && face_looks.values().all(|l| l.opacity >= 0.999); - let planned = (shaded && opaque && app.viewport.gpu.is_some_and(|g| g.tess)) - .then(|| b.display_plan()) - .flatten() - .filter(|plan| plan.vertex_count() <= solidcraft_gpu::MAX_BATCH); + let planned = drawn_plan(app, b, opaque); let on_gpu = planned.is_some(); if let Some(plan) = planned { let face_colors = (0..b.body.face_count()) @@ -1400,6 +1435,10 @@ fn highlight_key(app: &SolveApp) -> u64 { format!("{:?}", app.viewport.hover).hash(&mut h); app.viewport.hover_feature.hash(&mut h); app.viewport.hover_bodies.hash(&mut h); + // Which mesh lights a face follows how the model draws its body (see `shown_mesh`). + app.session.active_component.hash(&mut h); + app.session.active_occurrence_path.hash(&mut h); + app.ui.visual_style.hash(&mut h); app.ui.pick_bodies.hash(&mut h); crate::theme::is_dark().hash(&mut h); app.preview.replaced.hash(&mut h); @@ -1418,6 +1457,10 @@ fn build_highlight(app: &SolveApp) -> GpuScene { let t = Tokens::get(); let mut sc = GpuScene::default(); let st = app.session.world_state(); + let s = &app.session; + let active_bodies = s.active_occurrence_path.as_ref().and_then(|p| s.occurrence_bodies(p, true).ok()); + // Faces lit over the model use the triangles the model shows them with. + let shown = |b: &solidcraft_engine::doc::ModelBody| shown_mesh(app, b, &active_bodies); let mut sel = Vec::new(); for item in app.highlighted() { for piece in displayed_selection(app, &item) { @@ -1491,7 +1534,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene { Sel::Face { body, index, .. } => { // A live preview stands in for the body: its old faces would fight with it. if let Some(b) = st.body(body).filter(|_| app.session.body_visible(body) && !app.preview.replaced.contains(body)) { - face_tris(&mut sc, &b.mesh(), *index, c4(t.sel_face), false); + face_tris(&mut sc, &shown(b), *index, c4(t.sel_face), false); hidden_face_outline(&mut sc, &b.mesh(), *index, t.sel_edge); } } @@ -1499,7 +1542,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene { if let Some(b) = st.body(name) && app.session.body_visible(name) { - let m = b.mesh(); + let m = shown(b); for f in 0..b.body.face_count() { face_tris(&mut sc, &m, f, c4(t.body_selected), true); } @@ -1573,11 +1616,12 @@ fn build_highlight(app: &SolveApp) -> GpuScene { let k = t.hover_face_lift; let c = body_rgb(app, &b.name); let m = b.mesh(); + let lit = shown(b); for &f in &faces { if !sel.iter().any(|x| { matches!(x, Sel::Face { body, index, .. } if body == &b.name && *index == f) || matches!(x, Sel::Body { name } if name == &b.name) }) { - face_tris(&mut sc, &m, f, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true); + face_tris(&mut sc, &lit, f, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true); } } for (ei, e) in m.edges.iter().enumerate() { @@ -1593,7 +1637,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene { if let Some(b) = st.body(body) { let k = t.hover_face_lift; let c = body_rgb(app, body); - let m = b.mesh(); + let m = shown(b); for f in 0..b.body.face_count() { face_tris(&mut sc, &m, f, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true); } @@ -1608,8 +1652,9 @@ fn build_highlight(app: &SolveApp) -> GpuScene { let c = body_rgb(app, body); let m = b.mesh(); let patch = m.surface_patch(*index); + let lit = shown(b); for &f in &patch { - face_tris(&mut sc, &m, f, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true); + face_tris(&mut sc, &lit, f, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true); } for (e, _) in m.edge_faces.iter().enumerate().filter(|(_, fs)| fs.iter().any(|f| patch.contains(&(*f as usize)))) { if let Some(p) = m.edges.get(e) @@ -3272,7 +3317,7 @@ mod tests { let e = proj.cam.eye().to_f32(); let eye = [e[0], e[1], e[2], if proj.cam.fov > 0.0 { 1.0 } else { 0.0 }]; let planned = scene.planned.len(); - (planned, scene.tris.len(), crate::gpu::render_offscreen(scene, proj.vp.to_f32(), proj.cam.back().to_f32(), eye, SIZE, tess)) + (planned, scene.tris.len(), crate::gpu::render_offscreen(scene, None, proj.vp.to_f32(), proj.cam.back().to_f32(), eye, SIZE, tess)) }; let (planned, gpu_tris, gpu) = render(true); let (none, cpu_tris, cpu) = render(false); @@ -3290,6 +3335,62 @@ mod tests { assert!(differ * 100 < drawn, "{differ} of {drawn} model pixels differ"); } + /// A hovered face lights solidly over a body drawn from its display plan on the GPU: the + /// highlight uses the plan's triangles, not the reference mesh's other triangulation of the + /// same face, which fought the model at equal depth and shimmered in wedges. Every face of a + /// box with a pocket and a round boss, hovered, changes the same pixels as on the CPU path (where model and + /// highlight share the reference mesh). Skipped without a GPU adapter. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn a_hovered_face_lights_whole_over_a_body_drawn_from_its_plan() { + let mut s = solidcraft_engine::Session::default(); + s.execute("solid.box", &json!({"length": 40, "width": 40, "height": 40, "corner": [0, 0, 0], "operation": "new"})).unwrap(); + s.execute("solid.box", &json!({"length": 16, "width": 16, "height": 20, "corner": [12, 12, 20], "operation": "cut"})).unwrap(); + s.execute("solid.cylinder", &json!({"base": [33, 33, 40], "diameter": 10, "height": 10, "operation": "join"})).unwrap(); + let mut app = SolveApp::new(s, crate::Services::default()); + app.viewport.no_pixels = true; + app.fit_view(); + const SIZE: u32 = 256; + let rect = Rect::from_min_size(Pos2::ZERO, vec2(SIZE as f32, SIZE as f32)); + let body = app.session.world_state().bodies[0].clone(); + let faces = body.body.face_count(); + let mut render = |tess: bool, hover: Option| { + app.viewport.gpu = Some(GpuTarget { format: egui_wgpu::wgpu::TextureFormat::Rgba8Unorm, depth: None, samples: 1, tess }); + app.viewport.hover = hover.map(|index| Hit::Face { body: body.name.clone(), index, point: Vec3::ZERO }); + let (scene, highlight) = (build_scene(&app), build_highlight(&app)); + assert_eq!(!scene.planned.is_empty(), tess, "the box is drawn from its plan exactly on the GPU path"); + let proj = projection(&app, rect); + let e = proj.cam.eye().to_f32(); + let eye = [e[0], e[1], e[2], if proj.cam.fov > 0.0 { 1.0 } else { 0.0 }]; + crate::gpu::render_offscreen(scene, Some(highlight), proj.vp.to_f32(), proj.cam.back().to_f32(), eye, SIZE, tess) + }; + let lit = |plain: &[u8], hovered: &[u8]| -> Vec { plain.chunks(4).zip(hovered.chunks(4)).map(|(a, b)| a != b).collect() }; + let (Some(gpu_plain), Some(cpu_plain)) = (render(true, None), render(false, None)) else { + eprintln!("skipping: no GPU adapter"); + return; + }; + let mut checked = 0; + for f in 0..faces { + let (Some(gpu), Some(cpu)) = (render(true, Some(f)), render(false, Some(f))) else { return }; + let (on_gpu, on_cpu) = (lit(&gpu_plain, &gpu), lit(&cpu_plain, &cpu)); + let area = on_cpu.iter().filter(|x| **x).count(); + if area < 400 { + continue; // facing away or hidden + } + // Inside the face, two pixels in from its outline: a curved face's outline follows + // its own facets, which the plan and the reference mesh cut differently. + let n = SIZE as i32; + let inside = |i: usize| { + let (x, y) = (i as i32 % n, i as i32 / n); + (-2..=2).all(|dy| (-2..=2).all(|dx| on_cpu.get(((y + dy).clamp(0, n - 1) * n + (x + dx).clamp(0, n - 1)) as usize) == Some(&true))) + }; + let missed = (0..on_cpu.len()).filter(|&i| inside(i) && !on_gpu[i]).count(); + assert!(missed * 200 < area, "face {f}: {missed} of its {area} hovered pixels do not light (the highlight fights the model)"); + checked += 1; + } + assert!(checked >= 3, "{checked} faces seen lit"); + } + #[test] fn construction_drawing_and_hover_overlays_preserve_visible_gaps() { fn length(shape: &Shape) -> f32 {