* 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
@@ -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