View-dependent display refinement: finer curved faces and edges as you zoom in (#1320) (#1425)

* View-dependent display refinement: bodies seen up close are drawn from finer display plans (#1320)

Each frame the viewport works out every shown body's screen-space error (the display
quality's chord tolerance in pixels where the body is nearest the eye) and picks a
refinement step past the chosen display quality, each halving the chord, with hysteresis.
Steps are planned off the UI thread and swapped in when ready, within a triangle budget
and a cache size. Faces (GPU plan or CPU mesh), edges, silhouettes and highlights of a
refined body all come from the refined plan, so a highlight stays on the drawn triangles.
Planned bodies are chunked by content on the GPU, so a refinement re-evaluates only its
own chunk. Display only: the reference mesh, the B-rep and exports never change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Display refinement: only collect finished plans while the view stands still, time the choice, keep the web planning one step a frame

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Format

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Keep the Display quality note as it was (the longer one pushed the Colour vision list off the Graphics page)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bherbruckandClaude Opus 5.5 authored and GitHub committed 2026-10-15 11:03:53 -04:00
1 parent 8b97b9ed62
commit d43c414b7b
7 files changed
+805 -14

No files matched your search

+105
View File
@@ -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);
}
}
+19
View File
@@ -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<TessPlan>, Arc<Mesh>)> {
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)
+1
View File
@@ -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(),
+503
View File
@@ -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<TessPlan>, Arc<Mesh>)>;
/// 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<bool> = 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<Mesh>) {
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<f64>, 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<Mesh>,
refined: Refined,
bytes: usize,
used: u64,
}
#[cfg(not(target_arch = "wasm32"))]
struct Job {
key: (usize, DisplayQuality, u8),
holder: Arc<Mesh>,
rx: std::sync::mpsc::Receiver<Refined>,
}
/// 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<usize, u8>,
cache: HashMap<(usize, DisplayQuality, u8), Entry>,
#[cfg(not(target_arch = "wasm32"))]
jobs: Vec<Job>,
/// 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<Candidate<'_>>, live: &std::collections::HashSet<usize>) -> 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<usize> = (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<Mesh>, 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<usize>) {
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::<usize>()
}
#[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<usize> = [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<usize> = [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");
}
}
+14 -1
View File
@@ -295,6 +295,12 @@ fn joined<'a>(blocks: impl Iterator<Item = &'a Option<Batch>>, own: Option<Batch
(!parts.is_empty()).then_some(Batch { parts, chunk })
}
/// Planned bodies end a GPU chunk one time in this many (by their plan's address), and a chunk
/// holds at most [`MAX_CHUNK_BODIES`]: a scene of a thousand bodies takes a few dozen dispatches
/// once, and a changed body re-evaluates a few dozen bodies, not all of them.
const CHUNK_SPLIT: u64 = 32;
const MAX_CHUNK_BODIES: usize = 128;
#[derive(Default)]
struct Batches {
/// The scene blocks on the GPU, by key.
@@ -381,18 +387,25 @@ impl Batches {
encoder: &mut wgpu::CommandEncoder,
bodies: &[PlannedBody],
) -> Vec<PlannedDraw> {
// 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<&PlannedBody>> = 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::<Vec<usize>>();
self.resident.retain(|k, _| chunks.iter().any(|c| key(c) == *k));
+1
View File
@@ -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)]
+162 -13
View File
@@ -44,6 +44,11 @@ pub struct ViewportState {
pick_bounds: std::sync::Mutex<Option<BodyBounds>>,
/// The model scene's blocks of bodies by key, from the last scene built.
pub(crate) blocks: std::sync::Mutex<std::collections::HashMap<u64, CachedBlock>>,
/// 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<crate::display_lod::Lod>,
/// 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<std::collections::HashMap<usize, (std::sync::Arc<solidcraft_engine::geom::Mesh>, 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<std::sync::Arc<solidcraft_engine::geom::tess::TessPlan>> {
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<Vec<String>>,
) -> std::sync::Arc<solidcraft_engine::geom::Mesh> {
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<solidcraft_engine::geom::Mesh> {
/// 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<solidcraft_engine::geom::Mesh> {
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();