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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* Format

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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

+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");
}
}