* 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>
504 lines
21 KiB
Rust
504 lines
21 KiB
Rust
//! 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");
|
||
}
|
||
}
|