* 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:
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);
|
static CURRENT: AtomicU8 = AtomicU8::new(1);
|
||||||
|
|
||||||
/// The level the viewport draws at (process-wide; set from the preferences).
|
/// The level the viewport draws at (process-wide; set from the preferences).
|
||||||
@@ -140,4 +191,58 @@ mod tests {
|
|||||||
plan.vertex_count()
|
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()
|
.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.
|
/// The display mesh's chord tolerance: 1/1000 of the body's size.
|
||||||
fn display_tol(&self) -> f64 {
|
fn display_tol(&self) -> f64 {
|
||||||
(self.body.size() * 1e-3).max(1e-3)
|
(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_picker_busy": app.file_picker_busy(),
|
||||||
"file_dialog_open": app.file_picker_busy(),
|
"file_dialog_open": app.file_picker_busy(),
|
||||||
"build_ms": app.viewport.build_ms,
|
"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},
|
"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,
|
"home": app.home.open,
|
||||||
"recent_thumbnails_ready": app.home.ready_recents(),
|
"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 })
|
(!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)]
|
#[derive(Default)]
|
||||||
struct Batches {
|
struct Batches {
|
||||||
/// The scene blocks on the GPU, by key.
|
/// The scene blocks on the GPU, by key.
|
||||||
@@ -381,18 +387,25 @@ impl Batches {
|
|||||||
encoder: &mut wgpu::CommandEncoder,
|
encoder: &mut wgpu::CommandEncoder,
|
||||||
bodies: &[PlannedBody],
|
bodies: &[PlannedBody],
|
||||||
) -> Vec<PlannedDraw> {
|
) -> 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 chunks: Vec<Vec<&PlannedBody>> = Vec::new();
|
||||||
let mut filled = 0usize;
|
let mut filled = 0usize;
|
||||||
|
let mut open = false;
|
||||||
for b in bodies {
|
for b in bodies {
|
||||||
let n = b.plan.vertex_count();
|
let n = b.plan.vertex_count();
|
||||||
match chunks.last_mut() {
|
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]);
|
chunks.push(vec![b]);
|
||||||
filled = 0;
|
filled = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
filled += n;
|
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>>();
|
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));
|
self.resident.retain(|k, _| chunks.iter().any(|c| key(c) == *k));
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ mod file_picker;
|
|||||||
pub use file_picker::FilePickerFuture;
|
pub use file_picker::FilePickerFuture;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod blend_arrow_tests;
|
mod blend_arrow_tests;
|
||||||
|
pub(crate) mod display_lod;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod file_load_ui_tests;
|
mod file_load_ui_tests;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
+162
-13
@@ -44,6 +44,11 @@ pub struct ViewportState {
|
|||||||
pick_bounds: std::sync::Mutex<Option<BodyBounds>>,
|
pick_bounds: std::sync::Mutex<Option<BodyBounds>>,
|
||||||
/// The model scene's blocks of bodies by key, from the last scene built.
|
/// 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>>,
|
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.
|
/// 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)>>,
|
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).
|
/// 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.visual_style.hash(&mut h);
|
||||||
app.ui.ground_shadow.hash(&mut h);
|
app.ui.ground_shadow.hash(&mut h);
|
||||||
solidcraft_engine::doc::display_quality::current().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).
|
// 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);
|
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);
|
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
|
/// 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
|
/// 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
|
/// 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(
|
fn drawn_plan(
|
||||||
app: &SolveApp,
|
app: &SolveApp,
|
||||||
b: &solidcraft_engine::doc::ModelBody,
|
b: &solidcraft_engine::doc::ModelBody,
|
||||||
@@ -380,11 +387,22 @@ fn drawn_plan(
|
|||||||
) -> Option<std::sync::Arc<solidcraft_engine::geom::tess::TessPlan>> {
|
) -> Option<std::sync::Arc<solidcraft_engine::geom::tess::TessPlan>> {
|
||||||
let shaded = app.ui.visual_style != 2;
|
let shaded = app.ui.visual_style != 2;
|
||||||
(shaded && opaque && app.viewport.gpu.is_some_and(|g| g.tess))
|
(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()
|
.flatten()
|
||||||
.filter(|plan| plan.vertex_count() <= solidcraft_gpu::MAX_BATCH)
|
.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
|
/// 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
|
/// 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
|
/// 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,
|
b: &solidcraft_engine::doc::ModelBody,
|
||||||
active_bodies: &Option<Vec<String>>,
|
active_bodies: &Option<Vec<String>>,
|
||||||
) -> std::sync::Arc<solidcraft_engine::geom::Mesh> {
|
) -> 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 doc = &app.session.doc;
|
||||||
let opaque =
|
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);
|
|| !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))
|
.then(|| drawn_plan(app, b, true))
|
||||||
.flatten()
|
.flatten()
|
||||||
.and_then(|_| b.plan_mesh())
|
.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
|
/// 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
|
/// are not drawn from the plan on the GPU): a refined plan's mesh when the view has one for it
|
||||||
/// the plan's mesh at the chosen quality (finer or coarser faces and edges alike), falling back
|
/// (#1320), the reference mesh at Normal display quality, else the plan's mesh at the chosen
|
||||||
/// to the reference mesh when the body has no plan.
|
/// quality (finer or coarser faces and edges alike), falling back to the reference mesh when the
|
||||||
fn drawn_mesh(b: &solidcraft_engine::doc::ModelBody) -> std::sync::Arc<solidcraft_engine::geom::Mesh> {
|
/// 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};
|
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()) }
|
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
|
/// 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).
|
/// (held, so no other mesh can take their addresses while the block is kept).
|
||||||
pub(crate) struct CachedBlock {
|
pub(crate) struct CachedBlock {
|
||||||
@@ -561,7 +649,7 @@ fn build_scene(app: &SolveApp) -> GpuScene {
|
|||||||
.collect();
|
.collect();
|
||||||
sc.planned.push(crate::gpu::PlannedBody { plan, face_colors });
|
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);
|
let key = body.key(shaded, edges, hidden_edges, edge_col, hidden_col);
|
||||||
key.hash(&mut run_key);
|
key.hash(&mut run_key);
|
||||||
run.push(body);
|
run.push(body);
|
||||||
@@ -1683,6 +1771,7 @@ fn highlight_key(app: &SolveApp) -> u64 {
|
|||||||
app.viewport.hover_feature.hash(&mut h);
|
app.viewport.hover_feature.hash(&mut h);
|
||||||
app.viewport.hover_bodies.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`).
|
// 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_component.hash(&mut h);
|
||||||
app.session.active_occurrence_path.hash(&mut h);
|
app.session.active_occurrence_path.hash(&mut h);
|
||||||
app.ui.visual_style.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.
|
// 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)) {
|
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);
|
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 } => {
|
Sel::Body { name } => {
|
||||||
@@ -1799,7 +1888,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
|
|||||||
Sel::Edge { body, index, point } => {
|
Sel::Edge { body, index, point } => {
|
||||||
if let Some(b) = st.body(body)
|
if let Some(b) = st.body(body)
|
||||||
&& app.session.body_visible(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);
|
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 k = t.hover_face_lift;
|
||||||
let c = body_rgb(app, &b.name);
|
let c = body_rgb(app, &b.name);
|
||||||
let m = drawn_mesh(b);
|
let m = drawn_mesh(app, b);
|
||||||
let lit = shown(b);
|
let lit = shown(b);
|
||||||
for &f in &faces {
|
for &f in &faces {
|
||||||
if !sel.iter().any(|x| {
|
if !sel.iter().any(|x| {
|
||||||
@@ -1898,7 +1987,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
|
|||||||
if let Some(b) = st.body(body) {
|
if let Some(b) = st.body(body) {
|
||||||
let k = t.hover_face_lift;
|
let k = t.hover_face_lift;
|
||||||
let c = body_rgb(app, body);
|
let c = body_rgb(app, body);
|
||||||
let m = drawn_mesh(b);
|
let m = drawn_mesh(app, b);
|
||||||
let patch = m.surface_patch(*index);
|
let patch = m.surface_patch(*index);
|
||||||
let lit = shown(b);
|
let lit = shown(b);
|
||||||
for &f in &patch {
|
for &f in &patch {
|
||||||
@@ -1916,7 +2005,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
|
|||||||
Some(Hit::Edge { body, index, mid }) => {
|
Some(Hit::Edge { body, index, mid }) => {
|
||||||
if let Some(b) = st.body(body) {
|
if let Some(b) = st.body(body) {
|
||||||
// Lit over the drawn edges (finer away from Normal display quality).
|
// 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) {
|
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));
|
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) } {
|
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));
|
painter.add(Shape::mesh(mesh));
|
||||||
|
|
||||||
// ---- model ----
|
// ---- model ----
|
||||||
|
update_refinement(app, ui.ctx(), &proj);
|
||||||
let key = scene_key(app);
|
let key = scene_key(app);
|
||||||
let t0 = crate::now_ms();
|
let t0 = crate::now_ms();
|
||||||
if let Some(_g) = app.viewport.gpu {
|
if let Some(_g) = app.viewport.gpu {
|
||||||
@@ -4623,6 +4713,65 @@ mod tests {
|
|||||||
assert!(app.viewport.hover.is_some());
|
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 {
|
fn cylinder_app() -> SolveApp {
|
||||||
let mut s = solidcraft_engine::Session::default();
|
let mut s = solidcraft_engine::Session::default();
|
||||||
s.execute("solid.cylinder", &json!({"radius": 10, "height": 20})).unwrap();
|
s.execute("solid.cylinder", &json!({"radius": 10, "height": 20})).unwrap();
|
||||||
|
|||||||
Reference in new issue
Block a user