diff --git a/crates/doc/src/display_quality.rs b/crates/doc/src/display_quality.rs index d6795b86..041c3497 100644 --- a/crates/doc/src/display_quality.rs +++ b/crates/doc/src/display_quality.rs @@ -77,6 +77,57 @@ impl DisplayQuality { } } +/// Most refinement steps view-dependent display refinement takes past the chosen level (#1320): +/// each halves the chord tolerance, so the finest is 1/16 of the chosen level's. +pub const MAX_REFINE: u8 = 4; + +/// Screen-space error (physical pixels) above which a body is refined: a facet's sag shows. +pub const REFINE_PX: f64 = 1.0; +/// Error a refinement aims for (below [`REFINE_PX`], so a body just refined is not refined again +/// at the next small zoom step). +pub const TARGET_PX: f64 = 0.75; +/// Error below which a refined body steps back toward the chosen level. A refinement lands +/// between half [`TARGET_PX`] and [`TARGET_PX`], and a step back between this and twice this, so +/// zooming out or in by up to 2× from either never flips the level back. +pub const COARSEN_PX: f64 = 0.175; + +impl DisplayQuality { + /// Chord tolerance (mm) of a body of this size refined `k` steps past this level: halved + /// per step. + pub fn refined_chord(self, size: f64, k: u8) -> f64 { + self.chord(size) / f64::from(1u32 << k.min(MAX_REFINE)) + } + + /// Facet angle (radians) `k` steps past this level: an arc's sag goes with the square of its + /// angle, so the angle shrinks by √2 per step, keeping pace with the chord. + pub fn refined_angle(self, k: u8) -> f64 { + self.angle() / std::f64::consts::SQRT_2.powi(i32::from(k.min(MAX_REFINE))) + } +} + +/// The refinement step a body should be drawn at (0: the chosen level), from `err_px`: the +/// screen-space error of the chosen level, its chord tolerance in physical pixels where the body +/// is nearest the eye (0 for a body off screen or too small to matter). `current` is the step +/// drawn now; steps halve the error. A body is refined once its error passes [`REFINE_PX`], to the +/// first step under [`TARGET_PX`], and steps back only once the error falls under [`COARSEN_PX`], +/// to the coarsest step that stays under twice that: the band between keeps an orbit or a small +/// zoom from flipping levels. Never past `max` (at most [`MAX_REFINE`]). +pub fn refinement_for(err_px: f64, current: u8, max: u8) -> u8 { + let max = max.min(MAX_REFINE); + let current = current.min(max); + if !(err_px.is_finite() && err_px > 0.0) { + return 0; + } + let err = |k: u8| err_px / f64::from(1u32 << k); + if err(current) > REFINE_PX { + (current..=max).find(|&k| err(k) <= TARGET_PX).unwrap_or(max) + } else if err(current) < COARSEN_PX { + (0..=current).find(|&k| err(k) <= 2.0 * COARSEN_PX).unwrap_or(current) + } else { + current + } +} + static CURRENT: AtomicU8 = AtomicU8::new(1); /// The level the viewport draws at (process-wide; set from the preferences). @@ -140,4 +191,58 @@ mod tests { plan.vertex_count() }); } + + #[test] + fn refinement_follows_the_screen_space_error_with_hysteresis() { + // Off screen or tiny: the chosen level. + assert_eq!(refinement_for(0.0, 3, MAX_REFINE), 0); + assert_eq!(refinement_for(f64::NAN, 2, MAX_REFINE), 0); + // Fine already: stays. + assert_eq!(refinement_for(0.6, 0, MAX_REFINE), 0); + assert_eq!(refinement_for(1.0, 0, MAX_REFINE), 0); + // Past a pixel: the first step under the target (each halves the error). + assert_eq!(refinement_for(1.2, 0, MAX_REFINE), 1); + assert_eq!(refinement_for(5.0, 0, MAX_REFINE), 3); + assert_eq!(refinement_for(5.0, 1, MAX_REFINE), 3); + // Capped. + assert_eq!(refinement_for(1e6, 0, MAX_REFINE), MAX_REFINE); + assert_eq!(refinement_for(1e6, 0, 2), 2); + assert_eq!(refinement_for(1e6, 9, 99), MAX_REFINE); + // Zooming back out: kept until the error is well under a pixel, then the coarsest step + // that stays under half a pixel. + assert_eq!(refinement_for(5.0, 3, MAX_REFINE), 3); + assert_eq!(refinement_for(2.5, 3, MAX_REFINE), 3, "0.31 px at step 3: in the band"); + assert_eq!(refinement_for(1.2, 3, MAX_REFINE), 2, "0.15 px at step 3: down to 0.3 px"); + assert_eq!(refinement_for(0.05, 3, MAX_REFINE), 0); + // No flicker: any error, once settled, gives the same step again, and a zoom by up to + // 2x either way from where a level was chosen keeps it. + let mut e = 0.01; + while e < 1e4 { + for start in 0..=MAX_REFINE { + let k = refinement_for(e, start, MAX_REFINE); + assert_eq!(refinement_for(e, k, MAX_REFINE), k, "error {e}, from {start}"); + // Settled by zooming in (from the chosen level) or out (from the finest). + if start == 0 || start == MAX_REFINE { + for f in [0.5, 0.7, 1.0, 1.4, 2.0] { + let back = refinement_for(e * f, k, MAX_REFINE); + let zoomed_in = f >= 1.0; + assert!( + back == k || (zoomed_in && back > k && start == 0) || (!zoomed_in && back < k && start == MAX_REFINE), + "error {e} x{f} from {start}: {k} -> {back}" + ); + } + } + } + e *= 1.13; + } + } + + #[test] + fn refined_steps_halve_the_chord() { + let q = DisplayQuality::Normal; + assert_eq!(q.refined_chord(200.0, 0), q.chord(200.0)); + assert_eq!(q.refined_chord(200.0, 2), q.chord(200.0) / 4.0); + assert_eq!(q.refined_chord(200.0, 99), q.chord(200.0) / 16.0); + assert!((q.refined_angle(2) - q.angle() / 2.0).abs() < 1e-12); + } } diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 45124dc4..516f205d 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -126,6 +126,25 @@ impl ModelBody { .clone() } + /// The display plan and its mesh `k` steps finer than `q` (view-dependent refinement, #1320; + /// see [`crate::display_quality::refinement_for`]). Step 0 is the cached plan at `q`; finer + /// steps are made on each call and kept by the viewport, which evicts them when it runs over + /// its budget. `None` when the body has no plan at that step. + pub fn refined_display(&self, q: crate::display_quality::DisplayQuality, k: u8) -> Option<(Arc, Arc)> { + if k == 0 { + return Some((self.display_plan_at(q)?, self.plan_mesh_at(q)?)); + } + let plan = match self.body.display_plan_angular(q.refined_chord(self.body.size(), k), q.refined_angle(k)) { + Ok(plan) => plan, + Err(e) => { + log::debug!("{}: no display plan refined {k} steps ({e})", self.name); + return None; + } + }; + let mesh = self.body.display_mesh_of(&plan).ok()?; + Some((Arc::new(plan), Arc::new(mesh))) + } + /// 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/control.rs b/crates/ui-egui/src/control.rs index fbbb2359..e423f59b 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -245,6 +245,7 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) -> "file_picker_busy": app.file_picker_busy(), "file_dialog_open": app.file_picker_busy(), "build_ms": app.viewport.build_ms, + "display_lod": app.viewport.lod.lock().map(|l| serde_json::to_value(&l.stats).unwrap_or_default()).unwrap_or_default(), "preview": {"active": app.preview.active, "busy": app.preview.busy, "error": app.preview.error, "ms": app.preview.ms, "replaced": app.preview.replaced}, "home": app.home.open, "recent_thumbnails_ready": app.home.ready_recents(), diff --git a/crates/ui-egui/src/display_lod.rs b/crates/ui-egui/src/display_lod.rs new file mode 100644 index 00000000..e62bf5c0 --- /dev/null +++ b/crates/ui-egui/src/display_lod.rs @@ -0,0 +1,503 @@ +//! View-dependent display refinement (#1320): bodies seen up close are drawn from finer display +//! plans, so curved faces, their edges and silhouettes stay smooth as the view zooms in. +//! +//! Each frame the viewport works out every shown body's screen-space error: the chord tolerance +//! of the chosen display quality (Preferences ▸ Graphics) in physical pixels, where the body is +//! nearest the eye. [`refinement_for`] turns that into a refinement step (each halves the chord), +//! with hysteresis. A step not made yet is planned off the UI thread and swapped in when it is +//! ready; until then the body keeps the step it has. Only bodies on screen and big enough get +//! refined, within a triangle budget (the bodies that need it most first) and a cache size. +//! +//! The model scene draws a refined body's faces from the refined plan (on the GPU, or its CPU +//! mesh), and its edges and highlights from that plan's mesh, so a highlight keeps lying on the +//! drawn triangles (#1064). Display only: the reference mesh the model reads, exports and the +//! B-rep never change. + +use std::collections::HashMap; +use std::sync::Arc; + +use solidcraft_engine::doc::ModelBody; +use solidcraft_engine::doc::display_quality::{DisplayQuality, MAX_REFINE, refinement_for}; +use solidcraft_engine::geom::tess::TessPlan; +use solidcraft_engine::geom::{Aabb3, Mesh, Vec3}; + +/// Most triangles all refined bodies may draw together (on top of the rest of the model). +pub(crate) const TRIANGLE_BUDGET: usize = 6_000_000; +/// Most bytes the refined plans and meshes kept may take; the least recently drawn go first. +pub(crate) const CACHE_BYTES: usize = 768 << 20; +/// A body smaller than this on screen (its box's longer side, physical pixels) is not refined. +pub(crate) const MIN_PX: f64 = 32.0; +/// Refinements planned at once, off the UI thread. +#[cfg(not(target_arch = "wasm32"))] +const MAX_JOBS: usize = 2; + +/// A refined plan and its CPU mesh (none: the body has no plan at that step). +pub(crate) type Refined = Option<(Arc, Arc)>; + +/// Whether the viewport refines at all: `SOLIDCRAFT_DISPLAY_REFINE=0` turns it off (to compare +/// pictures and frame times with and without it). +pub(crate) fn enabled() -> bool { + static ON: std::sync::OnceLock = std::sync::OnceLock::new(); + *ON.get_or_init(|| std::env::var("SOLIDCRAFT_DISPLAY_REFINE").map_or(true, |v| v != "0")) +} + +/// A body's identity here: the address of its reference mesh, which is shared by every copy of +/// the same body across timeline snapshots and held by the cache so the address stays its own. +pub(crate) fn body_id(b: &ModelBody) -> (usize, Arc) { + let m = b.mesh(); + (Arc::as_ptr(&m) as usize, m) +} + +/// How the camera maps millimetres to pixels. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Lens { + pub eye: Vec3, + /// Unit direction from the target toward the eye. + pub back: Vec3, + /// Orthographic: pixels per millimetre everywhere. Perspective: pixels per millimetre at a + /// depth of 1 mm (divided by the depth). + pub scale: f64, + pub perspective: bool, + /// Nearest depth considered (mm), so a body around the eye does not ask for infinity. + pub near: f64, +} + +impl Lens { + /// The view of `cam` in a viewport `height_px` physical pixels high. + pub fn new(cam: &solidcraft_engine::render::Camera, height_px: f64) -> Lens { + let perspective = cam.fov > 0.0; + let scale = if perspective { height_px / (2.0 * (cam.fov * 0.5).tan()) } else { height_px / (2.0 * cam.half_height()) }; + Lens { eye: cam.eye(), back: cam.back(), scale, perspective, near: (cam.distance * 1e-2).max(1e-3) } + } + + /// Whether any of a box lies in front of the eye. + pub fn in_front(&self, b: &Aabb3) -> bool { + let toward = -self.back; + let far_corner = Vec3::new( + if toward.x > 0.0 { b.max.x } else { b.min.x }, + if toward.y > 0.0 { b.max.y } else { b.min.y }, + if toward.z > 0.0 { b.max.z } else { b.min.z }, + ); + !self.perspective || (far_corner - self.eye).dot(toward) > 0.0 + } + + /// Pixels per millimetre where a box comes nearest the eye. + pub fn px_per_mm(&self, b: &Aabb3) -> f64 { + if !self.perspective { + return self.scale; + } + // The box's nearest depth along the view: its support point toward the eye. + let toward = -self.back; + let near_corner = Vec3::new( + if toward.x > 0.0 { b.min.x } else { b.max.x }, + if toward.y > 0.0 { b.min.y } else { b.max.y }, + if toward.z > 0.0 { b.min.z } else { b.max.z }, + ); + let depth = (near_corner - self.eye).dot(toward); + self.scale / depth.max(self.near) + } +} + +/// The screen-space error (physical pixels) of drawing a body of `size` at `q` and its box's +/// projected extent (`screen_px`, physical pixels; `None` when the box is not on screen). +pub(crate) fn error_px(q: DisplayQuality, size: f64, bounds: &Aabb3, screen_px: Option, lens: &Lens) -> f64 { + match screen_px { + Some(px) if px >= MIN_PX => q.chord(size) * lens.px_per_mm(bounds), + _ => 0.0, + } +} + +/// One shown body as the refinement sees it this frame. +pub(crate) struct Candidate<'a> { + pub body: &'a ModelBody, + /// The chosen level's screen-space error ([`error_px`]). + pub err_px: f64, + /// Triangles at the chosen level (an estimate of the step-0 count). + pub triangles: usize, +} + +struct Entry { + /// Keeps the body's id address its own while the entry lives. + _holder: Arc, + refined: Refined, + bytes: usize, + used: u64, +} + +#[cfg(not(target_arch = "wasm32"))] +struct Job { + key: (usize, DisplayQuality, u8), + holder: Arc, + rx: std::sync::mpsc::Receiver, +} + +/// What the refinement holds now (`ui.inspect` → `display_lod`). +#[derive(Clone, Debug, Default, serde::Serialize)] +pub(crate) struct Stats { + /// Bodies drawn finer than the chosen level. + pub refined_bodies: usize, + /// Triangles those bodies draw. + pub refined_triangles: usize, + /// Refined plans and meshes kept, and their bytes. + pub cached: usize, + pub cache_bytes: usize, + /// Refinements being planned. + pub planning: usize, + /// Refinements planned so far, and the time they took (ms, off the UI thread). + pub planned: usize, + pub planning_ms: f64, + /// Time the last choice of steps took on the UI thread (ms). + pub choose_ms: f64, +} + +/// The refinement state of a viewport. +#[derive(Default)] +pub(crate) struct Lod { + /// The step each body is drawn at, by [`body_id`] (absent: 0, the chosen level). + shown: HashMap, + cache: HashMap<(usize, DisplayQuality, u8), Entry>, + #[cfg(not(target_arch = "wasm32"))] + jobs: Vec, + /// The web plans one refinement per frame: more are waiting. + #[cfg(target_arch = "wasm32")] + more: bool, + /// Bumped whenever a body's drawn step changes (part of the scene's key). + pub generation: u64, + clock: u64, + pub stats: Stats, +} + +impl Lod { + /// The refined plan and mesh `b` is drawn from, when it is drawn finer than the chosen level. + pub fn refined(&self, b: &ModelBody, q: DisplayQuality) -> Refined { + let (id, _) = body_id(b); + let k = *self.shown.get(&id)?; + self.cache.get(&(id, q, k))?.refined.clone() + } + + /// The step `b` is drawn at. + #[cfg(test)] + pub fn step(&self, b: &ModelBody) -> u8 { + self.shown.get(&body_id(b).0).copied().unwrap_or(0) + } + + /// Choose each candidate's step, start planning the steps not made yet, and swap in those + /// that are ready. `live` are the ids of every body in the model now (hidden ones too): the + /// cache drops the others. Returns whether any body's drawn step changed. + pub fn update(&mut self, q: DisplayQuality, candidates: Vec>, live: &std::collections::HashSet) -> bool { + self.clock += 1; + self.collect(); + let mut changed = false; + // The bodies that need it most first, within the triangle budget. + let mut order: Vec = (0..candidates.len()).collect(); + order.sort_by(|&a, &b| candidates[b].err_px.total_cmp(&candidates[a].err_px)); + let mut remaining = TRIANGLE_BUDGET; + let mut wanted: Vec<(usize, u8)> = Vec::new(); + let mut requests: Vec<(usize, u8)> = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for i in order { + let c = &candidates[i]; + let (id, _) = body_id(c.body); + if !seen.insert(id) { + continue; + } + let current = self.shown.get(&id).copied().unwrap_or(0); + let mut k = refinement_for(c.err_px, current, MAX_REFINE); + let estimate = |k: u8| self.cache.get(&(id, q, k)).and_then(|e| e.refined.as_ref()).map_or(c.triangles << k, |(_, m)| m.triangles.len()); + while k > 0 && estimate(k) > remaining { + k -= 1; + } + if k > 0 { + remaining -= estimate(k); + } + wanted.push((i, k)); + } + for (i, k) in wanted { + let (id, holder) = body_id(candidates[i].body); + let current = self.shown.get(&id).copied().unwrap_or(0); + if k == current { + continue; + } + if k == 0 { + self.shown.remove(&id); + changed = true; + continue; + } + match self.cache.get(&(id, q, k)) { + Some(e) if e.refined.is_some() => { + self.shown.insert(id, k); + changed = true; + } + // The planner refused this step: keep what is drawn. + Some(_) => {} + None => { + let _ = holder; + requests.push((i, k)); + } + } + } + self.start(q, &candidates, requests); + // Keep the steps drawn now from being evicted. + for (id, k) in &self.shown { + if let Some(e) = self.cache.get_mut(&(*id, q, *k)) { + e.used = self.clock; + } + } + self.shown.retain(|id, _| live.contains(id)); + self.evict(live); + self.refresh_stats(q); + if changed { + self.generation += 1; + } + changed + } + + /// Whether refinements are still being planned (the viewport repaints to collect them). + pub fn busy(&self) -> bool { + #[cfg(not(target_arch = "wasm32"))] + { + !self.jobs.is_empty() + } + #[cfg(target_arch = "wasm32")] + { + self.more + } + } + + fn insert(&mut self, key: (usize, DisplayQuality, u8), holder: Arc, refined: Refined) { + let bytes = refined.as_ref().map_or(0, |(plan, mesh)| mesh_bytes(mesh) + plan.vertex_count() * 48 + mesh.triangles.len() * 12); + self.cache.insert(key, Entry { _holder: holder, refined, bytes, used: self.clock }); + } + + /// Collect finished refinements; whether any finished. They are swapped in by the next + /// choice of steps ([`Lod::update`]). + #[cfg(not(target_arch = "wasm32"))] + pub fn collect(&mut self) -> bool { + let mut done = Vec::new(); + self.jobs.retain(|j| match j.rx.try_recv() { + Ok(r) => { + done.push((j.key, j.holder.clone(), r)); + false + } + Err(std::sync::mpsc::TryRecvError::Empty) => true, + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + done.push((j.key, j.holder.clone(), None)); + false + } + }); + let any = !done.is_empty(); + for (key, holder, r) in done { + self.insert(key, holder, r); + } + any + } + + #[cfg(target_arch = "wasm32")] + pub fn collect(&mut self) -> bool { + self.more + } + + /// Plan the requested steps: off the UI thread, a few at a time, the most needed first (the + /// web has no threads: one per frame, right away). + fn start(&mut self, q: DisplayQuality, candidates: &[Candidate<'_>], mut requests: Vec<(usize, u8)>) { + requests.sort_by(|a, b| candidates[b.0].err_px.total_cmp(&candidates[a.0].err_px)); + #[cfg(not(target_arch = "wasm32"))] + for (i, k) in requests { + if self.jobs.len() >= MAX_JOBS { + break; + } + let body = candidates[i].body.clone(); + let (id, holder) = body_id(&body); + let key = (id, q, k); + if self.jobs.iter().any(|j| j.key.0 == id) { + continue; + } + let (tx, rx) = std::sync::mpsc::channel(); + let spawned = std::thread::Builder::new().name("display-refine".into()).spawn(move || { + let t0 = std::time::Instant::now(); + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| body.refined_display(q, k))).ok().flatten(); + log::debug!("refined {} {k} steps in {:.1} ms", body.name, t0.elapsed().as_secs_f64() * 1e3); + PLANNING_US.fetch_add(t0.elapsed().as_micros() as u64, std::sync::atomic::Ordering::Relaxed); + let _ = tx.send(r); + }); + match spawned { + Ok(_) => self.jobs.push(Job { key, holder, rx }), + Err(e) => { + log::warn!("display refinement: {e}"); + break; + } + } + } + #[cfg(target_arch = "wasm32")] + { + self.more = requests.len() > 1; + } + #[cfg(target_arch = "wasm32")] + if let Some((i, k)) = requests.into_iter().next() { + let body = candidates[i].body; + let (id, holder) = body_id(body); + let r = body.refined_display(q, k); + self.insert((id, q, k), holder, r); + } + } + + /// Drop what belongs to bodies no longer in the model, then the least recently drawn steps + /// until the cache fits its budget. + fn evict(&mut self, live: &std::collections::HashSet) { + self.cache.retain(|(id, _, _), _| live.contains(id)); + let mut total: usize = self.cache.values().map(|e| e.bytes).sum(); + if total <= CACHE_BYTES { + return; + } + let mut by_age: Vec<((usize, DisplayQuality, u8), u64, usize)> = self.cache.iter().map(|(k, e)| (*k, e.used, e.bytes)).collect(); + by_age.sort_by_key(|(_, used, _)| *used); + for (key, used, bytes) in by_age { + if total <= CACHE_BYTES || used == self.clock { + break; + } + self.cache.remove(&key); + total -= bytes; + } + } + + fn refresh_stats(&mut self, q: DisplayQuality) { + let drawn: Vec<&Entry> = self.shown.iter().filter_map(|(id, k)| self.cache.get(&(*id, q, *k))).collect(); + self.stats.refined_bodies = drawn.len(); + self.stats.refined_triangles = drawn.iter().filter_map(|e| e.refined.as_ref()).map(|(_, m)| m.triangles.len()).sum(); + self.stats.cached = self.cache.len(); + self.stats.cache_bytes = self.cache.values().map(|e| e.bytes).sum(); + #[cfg(not(target_arch = "wasm32"))] + { + self.stats.planning = self.jobs.len(); + } + self.stats.planning_ms = PLANNING_US.load(std::sync::atomic::Ordering::Relaxed) as f64 / 1e3; + self.stats.planned = self.cache.len(); + } + + /// Plan a step right away and draw `b` at it (tests). + #[cfg(test)] + pub fn force(&mut self, b: &ModelBody, q: DisplayQuality, k: u8) -> bool { + let (id, holder) = body_id(b); + let r = b.refined_display(q, k); + let ok = r.is_some(); + self.insert((id, q, k), holder, r); + if ok { + self.shown.insert(id, k); + self.generation += 1; + } + ok + } +} + +/// Microseconds spent planning refinements (all threads). +static PLANNING_US: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +fn mesh_bytes(m: &Mesh) -> usize { + m.positions.len() * 24 + + m.normals.len() * 24 + + m.triangles.len() * 12 + + m.tri_face.len() * 4 + + m.edges.iter().map(|e| e.len() * 24).sum::() +} + +#[cfg(test)] +mod tests { + use super::*; + use solidcraft_engine::render::Camera; + + fn cylinder() -> ModelBody { + let body = solidcraft_engine::kernel::cylinder(Vec3::ZERO, Vec3::Z, 10.0, 20.0).unwrap(); + ModelBody::new("Body1".into(), body, 1) + } + + /// Zooming in raises the error with the pixels per millimetre, perspective or not, and a + /// box nearer the eye counts more. + #[test] + fn the_error_follows_the_zoom() { + let b = Aabb3 { min: Vec3::new(-10.0, -10.0, 0.0), max: Vec3::new(10.0, 10.0, 20.0) }; + let q = DisplayQuality::Normal; + for fov in [0.0, 0.6] { + let mut cam = Camera { distance: 200.0, fov, ..Camera::default() }; + let far = error_px(q, 30.0, &b, Some(300.0), &Lens::new(&cam, 1000.0)); + cam.distance = 50.0; + let near = error_px(q, 30.0, &b, Some(1200.0), &Lens::new(&cam, 1000.0)); + assert!(near > far * 3.0, "fov {fov}: {far} -> {near}"); + assert_eq!(error_px(q, 30.0, &b, None, &Lens::new(&cam, 1000.0)), 0.0, "off screen"); + assert_eq!(error_px(q, 30.0, &b, Some(MIN_PX / 2.0), &Lens::new(&cam, 1000.0)), 0.0, "too small"); + } + // Orthographic: chord × pixels per mm. + let cam = Camera { distance: 100.0, fov: 0.0, ..Camera::default() }; + let e = error_px(q, 30.0, &b, Some(500.0), &Lens::new(&cam, 800.0)); + assert!((e - q.chord(30.0) * 800.0 / (2.0 * cam.half_height())).abs() < 1e-12); + } + + /// A close-up refines the body (planned off the thread, then swapped in), a step finer has + /// more triangles and stays within its chord, and zooming back out returns to the chosen level. + #[test] + fn a_close_up_refines_and_zooming_out_returns() { + let b = cylinder(); + let q = DisplayQuality::Normal; + let live: std::collections::HashSet = [body_id(&b).0].into(); + let base = b.plan_mesh_at(q).unwrap().triangles.len(); + let mut lod = Lod::default(); + let cand = |err: f64| vec![Candidate { body: &b, err_px: err, triangles: base }]; + assert!(!lod.update(q, cand(0.5), &live)); + assert_eq!(lod.step(&b), 0); + // 6 px of error: three steps. + let mut swapped = false; + for _ in 0..2000 { + swapped |= lod.update(q, cand(6.0), &live); + if lod.step(&b) == 3 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(swapped && lod.step(&b) == 3, "refined to step {}", lod.step(&b)); + let (_, mesh) = lod.refined(&b, q).unwrap(); + assert!(mesh.triangles.len() > base * 2, "{} vs {base}", mesh.triangles.len()); + let r = |v: Vec3| (v.x * v.x + v.y * v.y).sqrt(); + let sag = mesh + .triangles + .iter() + .map(|t| t.map(|i| mesh.positions[i as usize])) + .filter(|t| t.iter().all(|p| (r(*p) - 10.0).abs() < 1e-9)) + .map(|[a, b, c]| 10.0 - r((a + b + c) * (1.0 / 3.0))) + .fold(0.0, f64::max); + assert!(sag <= q.refined_chord(b.body.size(), 3) * 1.01, "sag {sag}"); + // The reference mesh never changes. + assert!(Arc::ptr_eq(&body_id(&b).1, &b.mesh())); + // Zoomed out a little: kept; far out: the chosen level. + assert!(!lod.update(q, cand(3.0), &live)); + assert_eq!(lod.step(&b), 3); + assert!(lod.update(q, cand(0.1), &live)); + assert_eq!(lod.step(&b), 0); + assert!(lod.refined(&b, q).is_none()); + // Back in: swapped from the cache at once. + assert!(lod.update(q, cand(6.0), &live)); + assert_eq!(lod.step(&b), 3); + // The body leaves the model: dropped. + lod.update(q, Vec::new(), &Default::default()); + assert_eq!(lod.stats.cached, 0); + assert_eq!(lod.step(&b), 0); + } + + /// The budget refines the bodies that need it most and leaves the rest at the chosen level. + #[test] + fn the_budget_goes_to_the_largest_errors() { + let (a, b) = (cylinder(), cylinder()); + let q = DisplayQuality::Normal; + let live: std::collections::HashSet = [body_id(&a).0, body_id(&b).0].into(); + let mut lod = Lod::default(); + // Each would want four steps at 3M triangles a step-0 count: only the first fits. + let cands = vec![ + Candidate { body: &a, err_px: 2.0, triangles: TRIANGLE_BUDGET / 3 }, + Candidate { body: &b, err_px: 12.0, triangles: TRIANGLE_BUDGET / 3 }, + ]; + // Distinct bodies (two ModelBody::new calls), so distinct ids. + assert_ne!(body_id(&a).0, body_id(&b).0); + lod.update(q, cands, &live); + assert_eq!(lod.stats.planning, 1, "one refinement fits the budget"); + #[cfg(not(target_arch = "wasm32"))] + assert_eq!(lod.jobs[0].key.0, body_id(&b).0, "the larger error first"); + } +} diff --git a/crates/ui-egui/src/gpu.rs b/crates/ui-egui/src/gpu.rs index 531cb3b0..f0cf8c5b 100644 --- a/crates/ui-egui/src/gpu.rs +++ b/crates/ui-egui/src/gpu.rs @@ -295,6 +295,12 @@ fn joined<'a>(blocks: impl Iterator>, own: Option Vec { + // Chunks end where a body's own plan says (or when full), so a body whose plan changes + // (an edit, or a refinement as the view zooms in, #1320) re-evaluates only its own + // chunk, not the whole scene. let mut chunks: Vec> = Vec::new(); let mut filled = 0usize; + let mut open = false; for b in bodies { let n = b.plan.vertex_count(); match chunks.last_mut() { - Some(c) if filled + n <= solidcraft_gpu::MAX_BATCH => c.push(b), + Some(c) if open && filled + n <= solidcraft_gpu::MAX_BATCH => c.push(b), _ => { chunks.push(vec![b]); filled = 0; } } filled += n; + let mut h = std::collections::hash_map::DefaultHasher::new(); + std::hash::Hash::hash(&(Arc::as_ptr(&b.plan) as usize), &mut h); + open = !std::hash::Hasher::finish(&h).is_multiple_of(CHUNK_SPLIT) && chunks.last().is_some_and(|c| c.len() < MAX_CHUNK_BODIES); } let key = |c: &[&PlannedBody]| c.iter().map(|b| Arc::as_ptr(&b.plan) as usize).collect::>(); self.resident.retain(|k, _| chunks.iter().any(|c| key(c) == *k)); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index adbd0cb4..11662259 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -90,6 +90,7 @@ mod file_picker; pub use file_picker::FilePickerFuture; #[cfg(test)] mod blend_arrow_tests; +pub(crate) mod display_lod; #[cfg(test)] mod file_load_ui_tests; #[cfg(test)] diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index a09cf4aa..29860935 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -44,6 +44,11 @@ pub struct ViewportState { pick_bounds: std::sync::Mutex>, /// The model scene's blocks of bodies by key, from the last scene built. pub(crate) blocks: std::sync::Mutex>, + /// View-dependent display refinement (#1320): which bodies are drawn finer than the chosen + /// display quality, and their refined plans. + pub(crate) lod: std::sync::Mutex, + /// The view and model the refinement was last chosen for. + lod_view: u64, /// Each body mesh's box (the mesh held, so its address stays its own), from the last state. mesh_bounds: std::sync::Mutex, solidcraft_engine::geom::Aabb3)>>, /// The last click in the view was the middle button's (for the middle double-click). @@ -274,6 +279,7 @@ fn scene_key(app: &SolveApp) -> u64 { app.ui.visual_style.hash(&mut h); app.ui.ground_shadow.hash(&mut h); solidcraft_engine::doc::display_quality::current().hash(&mut h); + lod_generation(app).hash(&mut h); // The section plane (its cut outlines are part of the model scene). section_plane(app).map(|(o, n)| [o.x, o.y, o.z, n.x, n.y, n.z].map(f64::to_bits)).hash(&mut h); app.cam.up.hash(&mut h); @@ -372,7 +378,8 @@ const SS_SETTLE: f64 = 0.25; /// 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. +/// see-through ones (sorted and blended per triangle) are built here. A body seen up close is +/// drawn from its refined plan ([`crate::display_lod`]). fn drawn_plan( app: &SolveApp, b: &solidcraft_engine::doc::ModelBody, @@ -380,11 +387,22 @@ fn drawn_plan( ) -> Option> { let shaded = app.ui.visual_style != 2; (shaded && opaque && app.viewport.gpu.is_some_and(|g| g.tess)) - .then(|| b.display_plan()) + .then(|| refined(app, b).map(|(plan, _)| plan).or_else(|| b.display_plan())) .flatten() .filter(|plan| plan.vertex_count() <= solidcraft_gpu::MAX_BATCH) } +/// The refined plan and mesh `b` is drawn from, when the view has it drawn finer than the chosen +/// display quality (#1320). +fn refined(app: &SolveApp, b: &solidcraft_engine::doc::ModelBody) -> crate::display_lod::Refined { + app.viewport.lod.lock().ok()?.refined(b, solidcraft_engine::doc::display_quality::current()) +} + +/// Changes whenever a body's refinement changes (the scene and highlight keys). +fn lod_generation(app: &SolveApp) -> u64 { + app.viewport.lod.lock().map_or(0, |l| l.generation) +} + /// 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 [`drawn_mesh`]. A face highlighted over the model /// must use the same triangles: another triangulation of the same face fights it at equal depth @@ -394,6 +412,10 @@ fn shown_mesh( b: &solidcraft_engine::doc::ModelBody, active_bodies: &Option>, ) -> std::sync::Arc { + if let Some((_, mesh)) = refined(app, b) { + // Refined: faces drawn from the refined plan on the GPU or built from its mesh here. + return mesh; + } 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); @@ -401,18 +423,84 @@ fn shown_mesh( .then(|| drawn_plan(app, b, true)) .flatten() .and_then(|_| b.plan_mesh()) - .unwrap_or_else(|| drawn_mesh(b)) + .unwrap_or_else(|| drawn_mesh(app, b)) } /// The mesh the model scene builds `b` from on the CPU (its edges always, its faces when they -/// are not drawn from the plan on the GPU): the reference mesh at Normal display quality, else -/// the plan's mesh at the chosen quality (finer or coarser faces and edges alike), falling back -/// to the reference mesh when the body has no plan. -fn drawn_mesh(b: &solidcraft_engine::doc::ModelBody) -> std::sync::Arc { +/// are not drawn from the plan on the GPU): a refined plan's mesh when the view has one for it +/// (#1320), the reference mesh at Normal display quality, else the plan's mesh at the chosen +/// quality (finer or coarser faces and edges alike), falling back to the reference mesh when the +/// body has no plan. +fn drawn_mesh(app: &SolveApp, b: &solidcraft_engine::doc::ModelBody) -> std::sync::Arc { use solidcraft_engine::doc::display_quality::{DisplayQuality, current}; + if let Some((_, mesh)) = refined(app, b) { + return mesh; + } if current() == DisplayQuality::Normal { b.mesh() } else { b.plan_mesh().unwrap_or_else(|| b.mesh()) } } +/// Choose which bodies to draw finer than the chosen display quality for this view (#1320): +/// each shown body's screen-space error from its box on screen, refinements planned off the UI +/// thread and swapped in when ready. Keeps repainting while refinements are being planned. +fn update_refinement(app: &mut SolveApp, ctx: &egui::Context, proj: &Proj) { + if app.viewport.gpu.is_none() || !crate::display_lod::enabled() { + return; // The CPU picture draws the reference mesh. + } + let q = solidcraft_engine::doc::display_quality::current(); + // Nothing to choose again while the view, the model and the planning stand still. + let mut h = std::collections::hash_map::DefaultHasher::new(); + scene_key(app).hash(&mut h); + crate::sketch_display::projection_key(proj, app.viewport.pixels_per_point()).hash(&mut h); + let view = h.finish(); + // While refinements are being planned, the view standing still: only collect finished ones. + if view == app.viewport.lod_view { + let (busy, finished) = app.viewport.lod.lock().map_or((false, false), |mut l| { + let finished = l.collect(); + (l.busy(), finished) + }); + if busy { + ctx.request_repaint_after(std::time::Duration::from_millis(30)); + } + if !finished { + return; + } + } + let t0 = crate::now_ms(); + app.viewport.lod_view = view; + let st = app.session.world_state(); + let ppp = f64::from(app.viewport.pixels_per_point()); + let lens = crate::display_lod::Lens::new(&proj.cam, f64::from(proj.rect.height()) * ppp); + let bounds = body_bounds(app, &st); + let mut live = std::collections::HashSet::with_capacity(st.bodies.len()); + let mut candidates = Vec::new(); + for (b, (name, bx)) in st.bodies.iter().zip(bounds.iter()) { + let id = crate::display_lod::body_id(b).0; + live.insert(id); + if b.name != *name || b.body.is_mesh() { + continue; + } + let shown = app.session.body_visible(&b.name) && !app.preview.replaced.contains(&b.name); + let on_screen = match screen_box(proj, bx, 0.0).filter(|_| shown) { + Some(r) => r.intersects(proj.rect).then(|| f64::from(r.width().max(r.height())) * ppp), + // A corner behind the eye: the view is inside or right by the box. + None => (shown && proj.cam.fov > 0.0 && lens.in_front(bx)).then_some(f64::INFINITY), + }; + let err_px = crate::display_lod::error_px(q, b.body.size(), bx, on_screen, &lens); + candidates.push(crate::display_lod::Candidate { body: b, err_px, triangles: b.mesh().triangles.len() }); + } + let busy = match app.viewport.lod.lock() { + Ok(mut lod) => { + lod.update(q, candidates, &live); + lod.stats.choose_ms = crate::now_ms() - t0; + lod.busy() + } + Err(_) => false, + }; + if busy { + ctx.request_repaint_after(std::time::Duration::from_millis(30)); + } +} + /// A model scene block kept by the viewport: its bytes and the meshes they were built from /// (held, so no other mesh can take their addresses while the block is kept). pub(crate) struct CachedBlock { @@ -561,7 +649,7 @@ fn build_scene(app: &SolveApp) -> GpuScene { .collect(); sc.planned.push(crate::gpu::PlannedBody { plan, face_colors }); } - let body = BodyDraw { mesh: drawn_mesh(b), col, faded, opacity, face_looks, on_gpu }; + let body = BodyDraw { mesh: drawn_mesh(app, b), col, faded, opacity, face_looks, on_gpu }; let key = body.key(shaded, edges, hidden_edges, edge_col, hidden_col); key.hash(&mut run_key); run.push(body); @@ -1683,6 +1771,7 @@ fn highlight_key(app: &SolveApp) -> u64 { 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`). + lod_generation(app).hash(&mut h); app.session.active_component.hash(&mut h); app.session.active_occurrence_path.hash(&mut h); app.ui.visual_style.hash(&mut h); @@ -1783,7 +1872,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene { // 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, &shown(b), *index, c4(t.sel_face), false); - hidden_face_outline(&mut sc, &drawn_mesh(b), *index, t.sel_edge); + hidden_face_outline(&mut sc, &drawn_mesh(app, b), *index, t.sel_edge); } } Sel::Body { name } => { @@ -1799,7 +1888,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene { Sel::Edge { body, index, point } => { if let Some(b) = st.body(body) && app.session.body_visible(body) - && let Some(e) = edge_of(&drawn_mesh(b), *index, *point) + && let Some(e) = edge_of(&drawn_mesh(app, b), *index, *point) { edge_lines(&mut sc, e, t.sel_edge, t.sel_edge_rim, 3.0); } @@ -1863,7 +1952,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene { } let k = t.hover_face_lift; let c = body_rgb(app, &b.name); - let m = drawn_mesh(b); + let m = drawn_mesh(app, b); let lit = shown(b); for &f in &faces { if !sel.iter().any(|x| { @@ -1898,7 +1987,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 = drawn_mesh(b); + let m = drawn_mesh(app, b); let patch = m.surface_patch(*index); let lit = shown(b); for &f in &patch { @@ -1916,7 +2005,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene { Some(Hit::Edge { body, index, mid }) => { if let Some(b) = st.body(body) { // Lit over the drawn edges (finer away from Normal display quality). - let m = drawn_mesh(b); + let m = drawn_mesh(app, b); for i in m.circular_rim(*index) { let selected = sel.iter().any(|x| matches!(x, Sel::Edge { body: b, index: j, .. } if b == body && *j == i)); if !selected && let Some(e) = if i == *index { edge_of(&m, i, *mid) } else { m.edges.get(i) } { @@ -2439,6 +2528,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { painter.add(Shape::mesh(mesh)); // ---- model ---- + update_refinement(app, ui.ctx(), &proj); let key = scene_key(app); let t0 = crate::now_ms(); if let Some(_g) = app.viewport.gpu { @@ -4623,6 +4713,65 @@ mod tests { assert!(app.viewport.hover.is_some()); } + /// After a close-up refines a body (#1320), the model draws it from the refined plan (on the + /// GPU) or its mesh (on the CPU), its edges come from that mesh too, and a hovered face lights + /// exactly triangles the model draws, never another triangulation (#1064). + #[test] + fn a_refined_body_highlights_on_its_drawn_triangles() { + use solidcraft_engine::doc::display_quality::current; + let tris_of = |bytes: &[u8]| -> Vec<[u32; 9]> { + bytes + .chunks(3 * crate::gpu::TRI_SIZE) + .map(|t| { + let mut v: Vec<[u32; 3]> = t + .chunks(crate::gpu::TRI_SIZE) + .map(|c| std::array::from_fn(|i| u32::from_le_bytes(c[i * 4..i * 4 + 4].try_into().unwrap()))) + .collect(); + v.sort_unstable(); + std::array::from_fn(|i| v[i / 3][i % 3]) + }) + .collect() + }; + let mesh_tris = |m: &solidcraft_engine::geom::Mesh| -> std::collections::HashSet<[u32; 9]> { + let mut sc = GpuScene::default(); + for f in 0..m.tri_face.iter().map(|f| *f as usize + 1).max().unwrap_or(0) { + face_tris(&mut sc, m, f, [0; 4], true); + } + tris_of(&sc.tris).into_iter().collect() + }; + for tess in [true, false] { + let mut app = cylinder_app(); + app.viewport.no_pixels = true; + app.viewport.gpu = Some(GpuTarget { format: egui_wgpu::wgpu::TextureFormat::Rgba8Unorm, depth: None, samples: 1, tess }); + let body = app.session.world_state().bodies[0].clone(); + let before = build_scene(&app).merged(); + let key = scene_key(&app); + assert!(app.viewport.lod.lock().unwrap().force(&body, current(), 2)); + assert_ne!(scene_key(&app), key, "a refinement rebuilds the scene"); + let (plan, mesh) = refined(&app, &body).unwrap(); + assert!(mesh.triangles.len() > body.plan_mesh().unwrap().triangles.len() * 2); + let scene = build_scene(&app).merged(); + if tess { + assert!(std::sync::Arc::ptr_eq(&scene.planned[0].plan, &plan), "faces drawn from the refined plan"); + } else { + assert_eq!(scene.tris.len(), mesh.triangles.len() * 3 * crate::gpu::TRI_SIZE, "faces built from the refined mesh"); + assert!(scene.tris.len() > before.tris.len()); + } + // Edges (lines) from the refined mesh: finer polylines. + assert!(scene.lines.len() > before.lines.len(), "{} vs {}", scene.lines.len(), before.lines.len()); + let drawn = mesh_tris(&mesh); + let mut lit_faces = 0; + for index in 0..body.body.face_count() { + app.viewport.hover = Some(Hit::Face { body: body.name.clone(), index, point: Vec3::ZERO }); + let lit = tris_of(&build_highlight(&app).tris); + assert!(!lit.is_empty(), "face {index} lights"); + assert!(lit.iter().all(|t| drawn.contains(t)), "face {index}: every lit triangle is a drawn one"); + lit_faces += 1; + } + assert!(lit_faces >= 3, "{lit_faces} faces"); + } + } + fn cylinder_app() -> SolveApp { let mut s = solidcraft_engine::Session::default(); s.execute("solid.cylinder", &json!({"radius": 10, "height": 20})).unwrap();