* 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:
1 parent
8b97b9ed62
commit
d43c414b7b
7 files changed
+805
-14
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user