diff --git a/docs/tessellation.md b/docs/tessellation.md new file mode 100644 index 000000000..5640b2f32 --- /dev/null +++ b/docs/tessellation.md @@ -0,0 +1,93 @@ +# Entity tessellation paths + +How each acadrust `EntityType` becomes drawable geometry in OpenCADStudio, and +whether it passes through the [truck](https://github.com/ricosjp/truck) modeling +kernel. + +There are **two distinct meanings of "through truck"** in the codebase, and they +must not be conflated: + +1. **truck B-rep meshing** — the entity is rebuilt as a truck `Shell`/`Solid` + and triangulated by `MeshableShape::triangulation()`. This is the only path + that produces filled *mesh* triangles from a kernel. +2. **truck curve topology** — the entity is expressed as a truck `Edge`/`Wire`/ + `Vertex` and sampled into line segments by truck's `ParameterDivision1D`. It + touches truck *topology* but never triangulates a surface; the output is a + polyline. + +Everything else is **direct**: points/segments/triangles are emitted straight +from the entity's own data without any truck object. + +Entry points: `src/scene/convert/tess.rs` (`tessellate_entity`, the per-entity +dispatcher) → `src/scene/convert/tessellate.rs` (`tessellate`, the `TruckObject` +router) and `src/scene/convert/truck_tess.rs` (curve sampling). + +## Summary + +| Path | Entities | +|---|---| +| **truck B-rep mesh** (Shell → `triangulation`, with a direct per-surface fallback) | `Solid3D`, `Region`, `Body`, `Surface` | +| **truck curve topology** (Edge/Wire → `ParameterDivision1D` sampling) | `Line`, `Arc`, `Circle`, `Ellipse`, `Spline`, `LwPolyline`, `Polyline`, `Polyline2D`, `Polyline3D`, `Point` — *non-thick variants only* | +| **direct** (no truck — segments/triangles emitted directly) | everything else | + +> The Model/Design-tab primitives (BOX, SPHERE, CYLINDER, CONE, WEDGE, TORUS, +> PYRAMID, extrude/revolve/loft/sweep) are also true truck B-rep meshes +> (`truck_tess::tessellate_solid`), but they are created as `Solid3D` +> placeholders carrying a truck B-rep, not as their own `EntityType`. + +The only call site of `shell.triangulation()` for an imported entity is +`acis_to_truck::tessellate_sat_truck`. The four ACIS solids dispatch through +`solid3d_tess::tessellate_acis`, which tries that truck kernel first and falls +back to a bespoke per-surface LOD sampler (`tessellate_sat_lods`) when truck +cannot rebuild a face — so their path is **truck-with-direct-fallback**. + +Curved-surface tessellation density is radius-relative (`CURVE_REL_TOL`, a +fraction of the surface radius) so a cylinder's facet count matches the +circle/arc wire tessellation instead of exploding on large radii. + +## Full table + +| Entity | Output | Path | Notes | +|---|---|---|---| +| Arc | wire | direct (truck curve topology) | non-thick: truck `Edge` (`builder::circle_arc`) sampled via `ParameterDivision1D`; thick: direct `Lines` | +| AttributeDefinition | wire | direct | routes through the Text/MText LFF glyph stroke pipeline | +| AttributeEntity | wire | direct | same as AttributeDefinition; values supplied per Insert | +| Block | none | n/a | block-definition sentinel; not tessellated (referenced via Insert) | +| BlockEnd | none | n/a | block-definition end marker; no output | +| Body | mesh | truck-with-direct-fallback | 3D ACIS body → truck `Shell` → `triangulation`; fallback per-surface sampler | +| Circle | wire | direct (truck curve topology) | non-thick: truck `Wire` of two half-circle edges; thick: direct `Lines` | +| Dimension | wire | direct | baked-block path recurses on `D###` sub-entities; synthesis path emits lines/arrows/LFF text | +| Ellipse | wire | direct (truck curve topology) | truck `Wire`/`Edge` over a `BSplineCurve`, `ParameterDivision1D` sampled | +| Face3D | both | direct | edge `Lines` + direct fan-triangulated `fill_tris`; no truck B-rep | +| Hatch | both | direct | boundary outline not emitted to the wire set (#131 OOM); fill rasterized on GPU | +| Insert | wire | direct | expands block children and tessellates each via its own path; XCLIP filter applied | +| Leader | wire | direct | leader path + arrowhead + landing, direct `Lines` | +| Line | wire | direct (truck curve topology) | non-thick: truck `Edge` (`builder::line`); thick: direct `Lines` | +| LwPolyline | wire | direct (truck curve topology) | `plinegen` true: truck `Contour` with bulge arcs; else direct `SegmentedLines` | +| Mesh | both | direct | SubD mesh: edge `Lines` + direct fan-triangulated `fill_tris` | +| MLine | wire | direct | spine + offset lines + caps, direct `Lines` | +| MText | wire | direct | wrap-aware multi-line LFF glyph layout; inline formatting codes | +| MultiLeader | wire | direct | leader + landing + LFF text + frame + fill | +| Ole2Frame | wire | direct | bounding rectangle + diagonal cross | +| Point | wire | direct (truck topology) | truck `Vertex` → cross marker sized by PDSIZE | +| PolyfaceMesh | both | direct | face list: closed-polyline edges + direct fan-triangulated `fill_tris` | +| PolygonMesh | both | direct | M×N grid wireframe + direct fan-triangulated `fill_tris` | +| Polyline | wire | direct (truck curve topology) | heavy 3D polyline; truck `Contour` + bulge arcs | +| Polyline2D | wire | direct (truck curve topology) | 2D polyline with bulge; truck `Contour` | +| Polyline3D | wire | direct (truck curve topology) | linear-edge truck `Contour`, no bulge/thickness | +| RasterImage | wire | direct | boundary rectangle / clipping polygon | +| Ray | wire | direct | two-point `[base, base + dir×1e6]`, no sampling | +| Region | mesh | truck-with-direct-fallback | 2D planar ACIS body; same truck path as Solid3D | +| Seqend | none | n/a | vertex-sequence terminator sentinel; no output | +| Shape | wire | direct | small diamond marker at the insertion point | +| Solid | wire | direct | 2D SOLID: four quad edges as direct `Lines` | +| Solid3D | mesh | truck-with-direct-fallback | 3DSOLID: parse SAT/SAB → truck `Shell` → `triangulation`; fallback sampler | +| Spline | wire | direct (truck curve topology) | NURBS/BSpline truck `Edge`, `ParameterDivision1D` sampled | +| Table | both | direct | cell fills (`fill_tris`) + LFF cell text + grid lines | +| Text | wire | direct | LFF-font stroked glyph polylines | +| Tolerance | wire | direct | feature-control frame grid + per-cell LFF symbols | +| Underlay | wire | direct | boundary rectangle of the PDF/DWF reference | +| Unknown | none | n/a | unrecognized-entity sentinel; no output | +| Viewport | wire | direct | content-viewport frame rectangle (sheet viewport skipped) | +| Wipeout | wire | direct | boundary rectangle / clipping polygon | +| XLine | wire | direct | three-point infinite line, no sampling | diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 0608d36cf..78df28c52 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -2255,7 +2255,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { // parent Insert (AutoCAD behaviour). scene::pick::hit_test::click_hit_insert_hatch( p, - &self.tabs[i].scene.insert_hatches_for_click(), + &self.tabs[i].scene.insert_hatches_for_click()[..], view_rot, eye, bounds, @@ -2781,7 +2781,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { .or_else(|| { scene::pick::hit_test::click_hit_insert_hatch( p, - &self.tabs[i].scene.insert_hatches_for_click(), + &self.tabs[i].scene.insert_hatches_for_click()[..], view_rot, eye, bounds, diff --git a/src/scene/convert/acis_to_truck.rs b/src/scene/convert/acis_to_truck.rs index 6246875ea..cfa1f4ce0 100644 --- a/src/scene/convert/acis_to_truck.rs +++ b/src/scene/convert/acis_to_truck.rs @@ -28,15 +28,21 @@ use acadrust::entities::acis::{ use crate::scene::model::mesh_model::{MeshLodSet, MeshModel}; use crate::scene::convert::solid3d_tess::{ - apply_body_transform, body_transform, collect_face_loops, cone_axis_span, mesh_aabb, + apply_body_transform, body_transform, collect_face_loops, cone_axis_span, }; /// Slightly over 2π so revolution builders close the loop. const FULL: f64 = std::f64::consts::TAU + 0.2; /// Boundary sampling density for planar faces with curved (circular) edges. const BOUNDARY_SEGS: usize = 64; -/// Triangle mesh chord tolerance (world units). +/// Triangle mesh chord tolerance (world units) for planar faces, where the +/// surface itself adds no curvature. const MESH_TOL: f64 = 0.01; +/// Chord tolerance for curved faces, as a fraction of the surface radius, so +/// the facet count is radius-independent instead of exploding on large radii. +/// `0.1` = 10% chord error (~7 facets per full circle) — a deliberately coarse +/// setting that keeps the solid triangle budget low. +const CURVE_REL_TOL: f64 = 0.1; /// Analytic outward-normal rule for a face, used to orient triangles and /// supply smooth per-vertex normals (truck's face orientation is unreliable @@ -121,14 +127,14 @@ pub fn tessellate_sat_truck( let Some(surf_rec) = sat.resolve(face.surface()) else { continue; }; - let Some((faces, outward)) = build_face_group(sat, &face, surf_rec) else { + let Some((faces, outward, tol)) = build_face_group(sat, &face, surf_rec) else { continue; }; if faces.is_empty() { continue; } let shell: Shell = faces.into(); - let poly = shell.triangulation(MESH_TOL).to_polygon(); + let poly = shell.triangulation(tol).to_polygon(); append_group(&mut mesh, &poly, &outward); } @@ -143,11 +149,7 @@ pub fn tessellate_sat_truck( if let Some((m, tr, scale)) = body_transform(sat) { apply_body_transform(&mut mesh, &m, &tr, scale); } - let world_aabb = mesh_aabb(&mesh); - Some(MeshLodSet { - lods: vec![mesh], - world_aabb, - }) + Some(MeshLodSet::from_lods(vec![mesh])) } /// Build the truck face(s) + outward rule for one analytic ACIS face. @@ -155,7 +157,11 @@ fn build_face_group( sat: &SatDocument, face: &SatFace, surf_rec: &acadrust::entities::acis::SatRecord, -) -> Option<(Vec, Outward)> { +) -> Option<(Vec, Outward, f64)> { + // Chord tolerance scaled to a curved surface's radius, so the facet count + // is radius-independent (matching the circle/arc wire tessellation) rather + // than exploding on large radii. + let curve_tol = |radius: f64| (radius.abs() * CURVE_REL_TOL).max(1e-6); match surf_rec.entity_type.as_str() { "plane-surface" => { let plane = SatPlaneSurface::from_record(surf_rec)?; @@ -166,28 +172,37 @@ fn build_face_group( } else { [nx, ny, nz] }; - Some((vec![f], Outward::Const(vnorm(n)))) + Some((vec![f], Outward::Const(vnorm(n)), MESH_TOL)) } "cone-surface" => { let cone = SatConeSurface::from_record(surf_rec)?; + let tol = curve_tol(cone.radius()); let (faces, out) = cone_faces(sat, &cone)?; - Some((faces, out)) + Some((faces, out, tol)) } "sphere-surface" => { let sphere = SatSphereSurface::from_record(surf_rec)?; let (cx, cy, cz) = sphere.center(); - Some((sphere_faces(&sphere), Outward::Sphere { center: [cx, cy, cz] })) + let tol = curve_tol(sphere.radius()); + Some(( + sphere_faces(&sphere), + Outward::Sphere { center: [cx, cy, cz] }, + tol, + )) } "torus-surface" => { let torus = SatTorusSurface::from_record(surf_rec)?; let (cx, cy, cz) = torus.center(); let (nx, ny, nz) = torus.normal(); + // Tube (minor) curvature is the tighter of the two, so sampling it + // to the circle tolerance keeps the whole torus at least that smooth. + let tol = curve_tol(torus.minor_radius()); let out = Outward::Torus { center: [cx, cy, cz], axis: vnorm([nx, ny, nz]), major: torus.major_radius(), }; - Some((torus_faces(&torus), out)) + Some((torus_faces(&torus), out, tol)) } _ => None, } diff --git a/src/scene/convert/solid3d_tess.rs b/src/scene/convert/solid3d_tess.rs index 142d3b3ca..9909c5262 100644 --- a/src/scene/convert/solid3d_tess.rs +++ b/src/scene/convert/solid3d_tess.rs @@ -181,8 +181,7 @@ fn tessellate_sat_lods( if lods.is_empty() { return None; } - let world_aabb = mesh_aabb(&lods[0]); - Some(MeshLodSet { lods, world_aabb }) + Some(MeshLodSet::from_lods(lods)) } /// Extract the body's placement transform from the SAT document: a row-major @@ -243,6 +242,7 @@ fn scale_lod(base: LodConfig, facet_res: f64) -> LodConfig { /// World-XY AABB of the mesh — used by the render-pipeline LOD selector /// to pick a level based on projected pixel diagonal. +#[allow(dead_code)] // superseded by mesh_model::compute_mesh_aabb (3D); kept for reference pub(crate) fn mesh_aabb(mesh: &MeshModel) -> [f32; 4] { let mut min_x = f32::INFINITY; let mut min_y = f32::INFINITY; diff --git a/src/scene/mod.rs b/src/scene/mod.rs index f6d756305..532a6db54 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -570,6 +570,7 @@ fn offset_mesh_lod_set(mut set: MeshLodSet) -> MeshLodSet { if min_x.is_finite() { set.world_aabb = [min_x, min_y, max_x, max_y]; } + set.recompute_aabb(); set } @@ -626,6 +627,7 @@ fn transform_block_mesh_lod_set( if min_x.is_finite() { out.world_aabb = [min_x, min_y, max_x, max_y]; } + out.recompute_aabb(); out } @@ -762,6 +764,12 @@ pub struct Scene { image_cache: RefCell>)>>, /// Cached mesh models, keyed by geometry_epoch. mesh_cache: RefCell>)>>, + /// Cached block-INSERT hatches for hit-testing, keyed by geometry_epoch. + /// Building this explodes every model-space INSERT, so without the cache a + /// heavy block-instanced drawing re-explodes thousands of inserts on every + /// hover. The set is geometry-derived, so a camera move / hover never + /// invalidates it. + insert_hatch_cache: RefCell>)>>, /// Per-viewport wire cache for paper-space rendering. /// Maps vp_handle → (geometry_epoch, Arc>). viewport_wire_cache: RefCell>)>>, @@ -935,6 +943,7 @@ impl Scene { wipeout_cache: RefCell::new(None), image_cache: RefCell::new(None), mesh_cache: RefCell::new(None), + insert_hatch_cache: RefCell::new(None), viewport_wire_cache: RefCell::new(HashMap::default()), paper_sheet_cache: RefCell::new(None), paper_projected_cache: RefCell::new(HashMap::default()), @@ -2528,7 +2537,43 @@ impl Scene { /// handle so a click on a block-internal hatch can select the parent /// Insert (AutoCAD behaviour: sub-entities of a block aren't directly /// selectable; the click resolves to the Insert). - pub fn insert_hatches_for_click(&self) -> Vec<(Handle, HatchModel)> { + /// Whether a block definition (recursively) contains any Hatch. Memoised in + /// `memo` across calls; lets the pick path skip exploding solid-only blocks. + fn block_has_hatch( + &self, + block_name: &str, + memo: &mut std::collections::HashMap, + ) -> bool { + if let Some(&v) = memo.get(block_name) { + return v; + } + // Seed `false` first so a cyclic block reference terminates. + memo.insert(block_name.to_string(), false); + let result = self + .document + .block_records + .get(block_name) + .map(|br| { + br.entity_handles.iter().any(|&h| match self.document.get_entity(h) { + Some(EntityType::Hatch(_)) => true, + Some(EntityType::Insert(ins)) => self.block_has_hatch(&ins.block_name, memo), + _ => false, + }) + }) + .unwrap_or(false); + memo.insert(block_name.to_string(), result); + result + } + + pub fn insert_hatches_for_click(&self) -> Arc> { + { + let c = self.insert_hatch_cache.borrow(); + if let Some((epoch, ref arc)) = *c { + if epoch == self.geometry_epoch { + return Arc::clone(arc); + } + } + } let layout_block = self.current_layout_block_handle(); let layer_hidden = |layer: &str| { self.document @@ -2538,6 +2583,10 @@ impl Scene { .unwrap_or(false) }; let mut out: Vec<(Handle, HatchModel)> = Vec::new(); + // Exploding an INSERT to find block-internal hatches is expensive, so + // skip blocks that contain no hatch at all (the common case for solid- + // only blocks). The hatch-presence test is memoised across inserts. + let mut hatch_memo: std::collections::HashMap = std::collections::HashMap::new(); for entity in self.document.entities() { let EntityType::Insert(ins) = entity else { continue; @@ -2552,6 +2601,9 @@ impl Scene { ) { continue; } + if !self.block_has_hatch(&ins.block_name, &mut hatch_memo) { + continue; + } for sub in ins .explode_from_document(&self.document) .into_iter() @@ -2569,7 +2621,9 @@ impl Scene { } } } - out + let arc = Arc::new(out); + *self.insert_hatch_cache.borrow_mut() = Some((self.geometry_epoch, Arc::clone(&arc))); + arc } /// Wires that should participate in hit-testing, snapping, and selection. @@ -2672,35 +2726,30 @@ impl Scene { eye: glam::DVec3, bounds: iced::Rectangle, ) -> Option { - // Block-internal instances must be owned (transformed copies); keep - // them in a Vec and chain references alongside the top-level meshes so - // neither set is cloned wholesale. - let mut block_owned: Vec<(Handle, crate::scene::model::mesh_model::MeshModel)> = Vec::new(); - if !self.block_meshes.is_empty() { - let layout_block = self.current_layout_block_handle(); - for e in self.document.entities() { - if e.common().owner_handle != layout_block { - continue; - } - let EntityType::Insert(ins) = e else { continue }; - if !self.mesh_entity_visible(ins.common.handle) { - continue; - } - let mut sets = Vec::new(); - self.expand_block_meshes(&ins.block_name, &ins.get_transform(), 0, None, &mut sets); - for set in sets { - if let Some(m) = set.lods.into_iter().next() { - block_owned.push((ins.common.handle, m)); - } - } + // Reuse the renderer's expanded mesh set (top-level solids + per-INSERT + // block instances), cached per geometry epoch — so hover no longer + // re-expands every block instance on each move. Every `MeshLodSet` + // carries its handle (in `mesh.name`) and a 3D AABB. + let meshes = self.meshes_arc(); + // Broad-phase: project each solid's 3D AABB and skip the per-triangle + // ray test for those whose footprint isn't under the cursor — O(solids) + // cheap projections instead of O(total triangles) on every hover. + let candidates = meshes.iter().filter_map(|set| { + let m = set.lods.first()?; + if !pick::hit_test::aabb_under_cursor( + set.world_aabb, + set.z_aabb, + cursor, + view_rot, + eye, + bounds, + ) { + return None; } - } - let top = self - .meshes - .iter() - .filter_map(|(h, set)| set.lods.first().map(|m| (*h, m))); - let blk = block_owned.iter().map(|(h, m)| (*h, m)); - pick::hit_test::mesh_click_hit(cursor, top.chain(blk), view_rot, eye, bounds) + let handle = Handle::new(m.name.parse::().ok()?); + Some((handle, m)) + }); + pick::hit_test::mesh_click_hit(cursor, candidates, view_rot, eye, bounds) } /// Parent INSERT handles whose block-internal solid meshes fall in a diff --git a/src/scene/model/mesh_model.rs b/src/scene/model/mesh_model.rs index 1ca21184e..07b014a72 100644 --- a/src/scene/model/mesh_model.rs +++ b/src/scene/model/mesh_model.rs @@ -43,30 +43,55 @@ pub struct MeshLodSet { /// by the per-frame LOD selector to compute the projected pixel /// diagonal. pub world_aabb: [f32; 4], + /// World Z extent `[min_z, max_z]`. With `world_aabb` this is the full 3D + /// box, which the pick path projects to a screen rect to skip solids whose + /// footprint isn't under the cursor (O(solids) instead of ray-testing every + /// triangle). `verts` carry only the high half of the double-single + /// position, so the bound is f32-precise — fine for a conservative cull. + pub z_aabb: [f32; 2], +} + +/// 3D bounds of every LOD's vertices: `([min_x, min_y, max_x, max_y], [min_z, max_z])`. +pub fn compute_mesh_aabb(lods: &[MeshModel]) -> ([f32; 4], [f32; 2]) { + let (mut min_x, mut min_y, mut min_z) = (f32::INFINITY, f32::INFINITY, f32::INFINITY); + let (mut max_x, mut max_y, mut max_z) = + (f32::NEG_INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY); + for lod in lods { + for &[x, y, z] in &lod.verts { + if !x.is_finite() || !y.is_finite() || !z.is_finite() { + continue; + } + min_x = min_x.min(x); + min_y = min_y.min(y); + min_z = min_z.min(z); + max_x = max_x.max(x); + max_y = max_y.max(y); + max_z = max_z.max(z); + } + } + ([min_x, min_y, max_x, max_y], [min_z, max_z]) } impl MeshLodSet { + /// Build a set from its LODs, computing the 3D AABB. + pub fn from_lods(lods: Vec) -> Self { + let (world_aabb, z_aabb) = compute_mesh_aabb(&lods); + Self { lods, world_aabb, z_aabb } + } + /// Wrap a single MeshModel as a one-LOD set. Used by interactive /// commands that only produce one tessellation (e.g. truck-based /// BOX/CYLINDER creation). The LOD selector will pick slot 0 for /// every zoom level. pub fn from_single(mesh: MeshModel) -> Self { - let mut min_x = f32::INFINITY; - let mut min_y = f32::INFINITY; - let mut max_x = f32::NEG_INFINITY; - let mut max_y = f32::NEG_INFINITY; - for &[x, y, _] in &mesh.verts { - if !x.is_finite() || !y.is_finite() { - continue; - } - if x < min_x { min_x = x; } - if y < min_y { min_y = y; } - if x > max_x { max_x = x; } - if y > max_y { max_y = y; } - } - Self { - lods: vec![mesh], - world_aabb: [min_x, min_y, max_x, max_y], - } + Self::from_lods(vec![mesh]) + } + + /// Recompute `world_aabb` / `z_aabb` after the LODs' vertices were rewritten + /// (relative-to-eye re-split, INSERT transform). + pub fn recompute_aabb(&mut self) { + let (xy, z) = compute_mesh_aabb(&self.lods); + self.world_aabb = xy; + self.z_aabb = z; } } diff --git a/src/scene/pick/hit_test.rs b/src/scene/pick/hit_test.rs index 3f3f5d495..eb1753ced 100644 --- a/src/scene/pick/hit_test.rs +++ b/src/scene/pick/hit_test.rs @@ -138,6 +138,51 @@ pub fn click_hits_all<'a>( /// mesh triangle to screen space and test whether the cursor lands inside it. /// Returns the front-most hit (smallest projected depth). Lets meshed solids /// (whose only wire geometry is thin edges) be selected on their faces. +/// Conservative broad-phase: does the solid's 3D AABB project to a screen rect +/// that the cursor falls within (± `CLICK_THRESHOLD_PX`)? Lets the caller skip +/// ray-testing the triangles of solids whose footprint isn't under the cursor — +/// O(solids) cheap projections instead of O(triangles) per hover. Returns +/// `true` (don't cull) whenever any corner is at/behind the camera, where the +/// projected rect can't be trusted. +/// +/// `world_aabb` is `[min_x, min_y, max_x, max_y]`, `z_aabb` is `[min_z, max_z]`, +/// and `view_proj` is the same matrix `mesh_click_hit` projects vertices with. +pub fn aabb_under_cursor( + world_aabb: [f32; 4], + z_aabb: [f32; 2], + cursor: Point, + view_proj: Mat4, + eye: glam::DVec3, + bounds: Rectangle, +) -> bool { + let [min_x, min_y, max_x, max_y] = world_aabb; + let [min_z, max_z] = z_aabb; + if !min_x.is_finite() || !min_z.is_finite() { + return true; // degenerate bound — don't cull + } + let (mut sx0, mut sy0, mut sx1, mut sy1) = + (f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY); + for &z in &[min_z, max_z] { + for &(x, y) in &[(min_x, min_y), (max_x, min_y), (min_x, max_y), (max_x, max_y)] { + let rel = glam::DVec3::new(x as f64 - eye.x, y as f64 - eye.y, z as f64 - eye.z) + .as_vec3(); + let clip = view_proj * rel.extend(1.0); + if clip.w <= 1e-6 { + return true; // corner at/behind the camera — can't bound; keep it + } + let ndc = clip.truncate() / clip.w; + let px = (ndc.x + 1.0) * 0.5 * bounds.width; + let py = (1.0 - ndc.y) * 0.5 * bounds.height; + sx0 = sx0.min(px); + sy0 = sy0.min(py); + sx1 = sx1.max(px); + sy1 = sy1.max(py); + } + } + let m = CLICK_THRESHOLD_PX; + cursor.x >= sx0 - m && cursor.x <= sx1 + m && cursor.y >= sy0 - m && cursor.y <= sy1 + m +} + pub fn mesh_click_hit<'a>( cursor: Point, meshes: impl Iterator, diff --git a/src/scene/pipeline/mesh_gpu.rs b/src/scene/pipeline/mesh_gpu.rs index c2b1c4bf9..35bbbc5f4 100644 --- a/src/scene/pipeline/mesh_gpu.rs +++ b/src/scene/pipeline/mesh_gpu.rs @@ -65,7 +65,9 @@ pub struct MeshGpu { /// `(a,b)(b,c)(c,a)`. Used by the wireframe-mode render path so 3D /// solids draw as their triangle edges without needing the /// `POLYGON_MODE_LINE` device feature. + #[allow(dead_code)] // only the highlight overlay builds MeshGpu now (fill only) pub wire_index_buffer: wgpu::Buffer, + #[allow(dead_code)] pub wire_index_count: u32, } @@ -79,6 +81,7 @@ pub struct MeshLodGpu { /// How a solid mesh is highlighted this frame. #[derive(Copy, Clone, PartialEq, Eq)] pub enum Highlight { + #[allow(dead_code)] // the highlight overlay only builds Selected / Hover None, /// Hovered — light orange wash. Hover, @@ -97,7 +100,104 @@ impl Highlight { } } +// ── Batched mesh buffers ────────────────────────────────────────────────── +// +// One MeshGpu per solid means one vertex/index bind + draw call per solid — +// ~10k draw calls a frame on a heavy 3D model, which strangles the GPU front +// end. The batch concatenates every solid's LOD0 geometry into a handful of +// large buffers (split only to stay under the 256 MB per-buffer cap), so the +// whole mesh set draws in a few calls. Vertices already carry their own colour, +// so no per-mesh state is needed between draws. Built once per geometry epoch — +// selection/hover no longer rebuild it (that tint is dropped in the batch path). + +/// Cap on vertices per batch buffer: 40 B/vertex × 6 M ≈ 240 MB, under the +/// default 256 MB `max_buffer_size`. Solids beyond the cap spill into the next +/// chunk. +const BATCH_MAX_VERTS: usize = 6_000_000; + +pub struct MeshBatchChunk { + pub vertex_buffer: wgpu::Buffer, + pub index_buffer: wgpu::Buffer, + pub index_count: u32, + pub wire_index_buffer: wgpu::Buffer, + pub wire_index_count: u32, +} + +fn make_chunk( + device: &wgpu::Device, + verts: &[MeshVertex], + indices: &[u32], + wire_indices: &[u32], +) -> MeshBatchChunk { + let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("mesh.batch.vbuf"), + contents: bytemuck::cast_slice(verts), + usage: wgpu::BufferUsages::VERTEX, + }); + let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("mesh.batch.ibuf"), + contents: bytemuck::cast_slice(indices), + usage: wgpu::BufferUsages::INDEX, + }); + let wire_index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("mesh.batch.wire_ibuf"), + contents: bytemuck::cast_slice(wire_indices), + usage: wgpu::BufferUsages::INDEX, + }); + MeshBatchChunk { + vertex_buffer, + index_buffer, + index_count: indices.len() as u32, + wire_index_buffer, + wire_index_count: wire_indices.len() as u32, + } +} + +/// Concatenate every set's first non-empty LOD into a few large GPU buffers. +/// Returns the chunks plus the total triangle count drawn (for diagnostics). +pub fn build_mesh_batch(device: &wgpu::Device, sets: &[MeshLodSet]) -> (Vec, u64) { + let mut chunks = Vec::new(); + let mut verts: Vec = Vec::new(); + let mut indices: Vec = Vec::new(); + let mut wire_indices: Vec = Vec::new(); + let mut total_tris = 0u64; + for set in sets { + let Some(mesh) = set.lods.iter().find(|m| !m.indices.is_empty()) else { + continue; + }; + if !verts.is_empty() && verts.len() + mesh.verts.len() > BATCH_MAX_VERTS { + chunks.push(make_chunk(device, &verts, &indices, &wire_indices)); + verts.clear(); + indices.clear(); + wire_indices.clear(); + } + let base = verts.len() as u32; + let has_normals = mesh.normals.len() == mesh.verts.len(); + for (i, &pos) in mesh.verts.iter().enumerate() { + verts.push(MeshVertex { + position: pos, + normal: if has_normals { mesh.normals[i] } else { [0.0, 1.0, 0.0] }, + color: mesh.color, + position_low: mesh.verts_low.get(i).copied().unwrap_or([0.0; 3]), + }); + } + for &idx in &mesh.indices { + indices.push(base + idx); + } + for tri in mesh.indices.chunks_exact(3) { + let (a, b, c) = (base + tri[0], base + tri[1], base + tri[2]); + wire_indices.extend_from_slice(&[a, b, b, c, c, a]); + } + total_tris += (mesh.indices.len() / 3) as u64; + } + if !indices.is_empty() { + chunks.push(make_chunk(device, &verts, &indices, &wire_indices)); + } + (chunks, total_tris) +} + impl MeshLodGpu { + #[allow(dead_code)] // built by the bypassed per-mesh upload_meshes path pub fn new(device: &wgpu::Device, set: &MeshLodSet, highlight: Highlight) -> Self { Self { lods: set diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 80497e381..5f664147b 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -45,6 +45,7 @@ pub struct Pipeline { hatch_batched_pipeline: Option, image_pipeline: wgpu::RenderPipeline, mesh_pipeline: wgpu::RenderPipeline, + mesh_highlight_pipeline: wgpu::RenderPipeline, /// Wireframe variant of the mesh pipeline (LineList topology, same /// vertex layout / shader). Used when the active render mode is /// Wireframe 2D or Wireframe 3D so 3D solids draw as their @@ -109,6 +110,19 @@ pub struct Pipeline { /// Pixel scissor rects [x, y, w, h] for viewport-clipped images. Recomputed each frame. image_pixel_scissors: Vec>, gpu_meshes: Vec, + /// Batched mesh geometry — every solid's LOD0 concatenated into a few large + /// buffers so the whole set draws in a handful of calls instead of one per + /// solid. Rebuilt only when the geometry epoch changes (see + /// `cached_mesh_batch_epoch`), so hover / selection never re-pack it. + gpu_mesh_batch: Vec, + /// `geometry_epoch` the batch was built for. `u64::MAX` = not built yet. + pub cached_mesh_batch_epoch: u64, + /// Tinted copies of just the selected / hovered solids, drawn over the + /// static base batch so highlight shows without re-packing the whole set. + /// Rebuilt only when the selection/hover or geometry changes — O(highlighted). + gpu_mesh_highlight: Vec, + /// `(geometry_epoch, selection_generation)` the highlight overlay was built for. + pub cached_highlight_key: (u64, u64), /// Per-mesh LOD level (0=high, 1=mid, 2=low) picked each frame from /// the projected pixel diagonal. Mirrors `hatch_pixel_scissors` — /// recomputed in `compute_mesh_lod`. @@ -143,7 +157,15 @@ pub struct Pipeline { /// `(geometry_epoch, selection_generation)` the resident mesh buffers were /// uploaded for. Bumps on a selection/hover change so highlighted solids /// re-upload with their tint. + #[allow(dead_code)] // per-mesh LOD path, bypassed by the batched mesh draw pub cached_mesh_key: (u64, u64), + /// `(geometry_epoch, face3d_fill_active)` the Face3D edge/fill buffers were + /// uploaded for. The buffers are world-space and selection-independent, so + /// they only change when the geometry changes or the 3D-fill mode toggles — + /// never on a pan/orbit. Keyed separately from `cached_epoch` (which carries + /// `camera_generation`) so a camera move no longer re-walks every wire to + /// rebuild the Face3D fill buffer. + pub cached_face3d_key: (u64, bool), /// Handle → indices into the resident wire set, built once per wire upload /// (when `cached_wire_id` changes). Lets the selection/hover xray overlay /// gather just the highlighted entity's wires (`O(highlighted)`) instead of @@ -510,6 +532,50 @@ impl Pipeline { cache: None, }); + // Highlight variant — same shader / vertex layout, but `depth_compare = + // Always` and no depth write, so a hovered / selected solid's tint is + // drawn on top of everything (it shows through occluding geometry) and + // doesn't disturb the depth buffer for later passes. + let mesh_highlight_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("mesh.highlight.pipeline"), + layout: Some(&mesh_layout), + vertex: wgpu::VertexState { + module: &mesh_shader, + entry_point: Some("vs_main"), + buffers: &[mesh_gpu::MeshVertex::layout()], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }, + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + cull_mode: None, + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth32Float, + depth_write_enabled: false, + depth_compare: wgpu::CompareFunction::Always, + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState { + count: MSAA_SAMPLES, + mask: !0, + alpha_to_coverage_enabled: false, + }, + fragment: Some(wgpu::FragmentState { + module: &mesh_shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }), + multiview: None, + cache: None, + }); + // Wireframe variant — same shader / vertex layout / depth state, // only the input topology changes (LineList) and back-face // culling drops out (each triangle edge is shared between two @@ -937,6 +1003,7 @@ impl Pipeline { hatch_batched_pipeline, image_pipeline, mesh_pipeline, + mesh_highlight_pipeline, mesh_wireframe_pipeline, mesh_depth_pipeline, face3d_pipeline, @@ -967,6 +1034,10 @@ impl Pipeline { gpu_images: vec![], image_pixel_scissors: vec![], gpu_meshes: vec![], + gpu_mesh_batch: vec![], + cached_mesh_batch_epoch: u64::MAX, + gpu_mesh_highlight: vec![], + cached_highlight_key: (u64::MAX, u64::MAX), mesh_lod_levels: vec![], mesh_visible: vec![], gpu_face3d_fill: None, @@ -976,6 +1047,7 @@ impl Pipeline { cached_wire_id: u64::MAX, cached_selection: (u64::MAX, u64::MAX), cached_mesh_key: (u64::MAX, u64::MAX), + cached_face3d_key: (u64::MAX, false), wire_handle_index: rustc_hash::FxHashMap::default(), } } @@ -1194,6 +1266,7 @@ impl Pipeline { } } + #[allow(dead_code)] // per-mesh upload path, bypassed by upload_mesh_batch pub fn upload_meshes( &mut self, device: &wgpu::Device, @@ -1222,6 +1295,46 @@ impl Pipeline { .collect(); } + /// Build the batched mesh buffers (a few large buffers for the whole solid + /// set, drawn in a handful of calls). Selection/hover tint is intentionally + /// not applied here — the batch is geometry-only and stays resident across + /// camera moves and pick changes. + pub fn upload_mesh_batch(&mut self, device: &wgpu::Device, meshes: &[MeshLodSet]) { + let (chunks, _tris) = mesh_gpu::build_mesh_batch(device, meshes); + self.gpu_mesh_batch = chunks; + } + + /// Build tinted copies of just the selected / hovered solids. Drawn over the + /// static base batch with the LessEqual mesh pipeline so the tint shows on + /// top at the same depth. O(highlighted), so a pick never touches the rest. + pub fn upload_mesh_highlight( + &mut self, + device: &wgpu::Device, + meshes: &[MeshLodSet], + selected: &rustc_hash::FxHashSet, + hover: Option, + ) { + use mesh_gpu::{Highlight, MeshGpu}; + let mut out = Vec::new(); + if selected.is_empty() && hover.is_none() { + self.gpu_mesh_highlight = out; + return; + } + for set in meshes { + let Some(m) = set.lods.iter().find(|m| !m.indices.is_empty()) else { + continue; + }; + let h = m.name.parse::().ok().map(acadrust::Handle::new); + let mode = match h { + Some(h) if selected.contains(&h) => Highlight::Selected, + Some(h) if Some(h) == hover => Highlight::Hover, + _ => continue, + }; + out.push(MeshGpu::new(device, m, mode)); + } + self.gpu_mesh_highlight = out; + } + /// Per-frame mesh LOD selector. Picks slot 0/1/2 based on the /// projected pixel diagonal of each mesh's `world_aabb` (Phase 3.4 /// ladder: >200 px → 0, 50–200 → 1, <50 → 2). Falls back to the @@ -1413,8 +1526,8 @@ impl Pipeline { } } - // ── Pass 4: solid meshes ────────────────────────────────────────── - if !self.gpu_meshes.is_empty() { + // ── Pass 4: solid meshes (batched) ──────────────────────────────── + if !self.gpu_mesh_batch.is_empty() { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("mesh.render_pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { @@ -1450,51 +1563,31 @@ impl Pipeline { // wire pass keeps the edges crisp on // top of the shaded surface. let want_solid_with_edges = !hidden_line && !mesh_wireframe && show_3d_edges; + // Each path now binds a chunk's buffers and draws the whole chunk in + // one call — a handful of draws total instead of one per solid. No + // per-solid LOD / frustum cull in the batched path (the batch is + // resident in full); that is reintroduced separately if needed. if hidden_line { pass.set_pipeline(&self.mesh_depth_pipeline); - for (i, set) in self.gpu_meshes.iter().enumerate() { - if !self.mesh_visible.get(i).copied().unwrap_or(true) { + for c in &self.gpu_mesh_batch { + if c.index_count == 0 { continue; } - let level = self - .mesh_lod_levels - .get(i) - .copied() - .unwrap_or(0) - .min(set.lods.len().saturating_sub(1)); - let Some(mesh) = set.lods.get(level) else { - continue; - }; - if mesh.index_count == 0 { - continue; - } - pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..)); - pass.set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32); - pass.draw_indexed(0..mesh.index_count, 0, 0..1); + pass.set_vertex_buffer(0, c.vertex_buffer.slice(..)); + pass.set_index_buffer(c.index_buffer.slice(..), wgpu::IndexFormat::Uint32); + pass.draw_indexed(0..c.index_count, 0, 0..1); } pass.set_pipeline(&self.mesh_wireframe_pipeline); - for (i, set) in self.gpu_meshes.iter().enumerate() { - if !self.mesh_visible.get(i).copied().unwrap_or(true) { + for c in &self.gpu_mesh_batch { + if c.wire_index_count == 0 { continue; } - let level = self - .mesh_lod_levels - .get(i) - .copied() - .unwrap_or(0) - .min(set.lods.len().saturating_sub(1)); - let Some(mesh) = set.lods.get(level) else { - continue; - }; - if mesh.wire_index_count == 0 { - continue; - } - pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..)); + pass.set_vertex_buffer(0, c.vertex_buffer.slice(..)); pass.set_index_buffer( - mesh.wire_index_buffer.slice(..), + c.wire_index_buffer.slice(..), wgpu::IndexFormat::Uint32, ); - pass.draw_indexed(0..mesh.wire_index_count, 0, 0..1); + pass.draw_indexed(0..c.wire_index_count, 0, 0..1); } } else { if mesh_wireframe { @@ -1502,59 +1595,48 @@ impl Pipeline { } else { pass.set_pipeline(&self.mesh_pipeline); } - for (i, set) in self.gpu_meshes.iter().enumerate() { - if !self.mesh_visible.get(i).copied().unwrap_or(true) { - continue; - } - let level = self - .mesh_lod_levels - .get(i) - .copied() - .unwrap_or(0) - .min(set.lods.len().saturating_sub(1)); - let Some(mesh) = set.lods.get(level) else { - continue; - }; + for c in &self.gpu_mesh_batch { let (ibuf, icount) = if mesh_wireframe { - (&mesh.wire_index_buffer, mesh.wire_index_count) + (&c.wire_index_buffer, c.wire_index_count) } else { - (&mesh.index_buffer, mesh.index_count) + (&c.index_buffer, c.index_count) }; if icount == 0 { continue; } - pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..)); + pass.set_vertex_buffer(0, c.vertex_buffer.slice(..)); pass.set_index_buffer(ibuf.slice(..), wgpu::IndexFormat::Uint32); pass.draw_indexed(0..icount, 0, 0..1); } - // *WithEdges variants: overlay wire-edge segments on top - // of the shaded fill. The LessEqual depth test on the - // wireframe pipeline keeps the edges visible at the - // fragments that just got written by the solid pass. + // Selection / hover highlight: tinted copies of the picked + // solids, drawn last with the Always-depth highlight pipeline so + // the tint shows on top even when the solid is behind others. + if !self.gpu_mesh_highlight.is_empty() { + pass.set_pipeline(&self.mesh_highlight_pipeline); + for g in &self.gpu_mesh_highlight { + if g.index_count == 0 { + continue; + } + pass.set_vertex_buffer(0, g.vertex_buffer.slice(..)); + pass.set_index_buffer(g.index_buffer.slice(..), wgpu::IndexFormat::Uint32); + pass.draw_indexed(0..g.index_count, 0, 0..1); + } + } + // *WithEdges variants: overlay wire-edge segments on top of the + // shaded fill. The LessEqual depth test on the wireframe pipeline + // keeps the edges visible over the fragments the solid pass wrote. if want_solid_with_edges { pass.set_pipeline(&self.mesh_wireframe_pipeline); - for (i, set) in self.gpu_meshes.iter().enumerate() { - if !self.mesh_visible.get(i).copied().unwrap_or(true) { + for c in &self.gpu_mesh_batch { + if c.wire_index_count == 0 { continue; } - let level = self - .mesh_lod_levels - .get(i) - .copied() - .unwrap_or(0) - .min(set.lods.len().saturating_sub(1)); - let Some(mesh) = set.lods.get(level) else { - continue; - }; - if mesh.wire_index_count == 0 { - continue; - } - pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..)); + pass.set_vertex_buffer(0, c.vertex_buffer.slice(..)); pass.set_index_buffer( - mesh.wire_index_buffer.slice(..), + c.wire_index_buffer.slice(..), wgpu::IndexFormat::Uint32, ); - pass.draw_indexed(0..mesh.wire_index_count, 0, 0..1); + pass.draw_indexed(0..c.wire_index_count, 0, 0..1); } } } diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 784b66596..4f1d56aea 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -263,6 +263,16 @@ impl shader::Primitive for Primitive { if geo_changed { inner.upload_images(device, queue, &vp.images[..]); } + inner.cached_epoch = cur_key; + } + // Face3D edge/fill buffers are world-space and selection-independent + // (upload_face3d takes no selection input), so they only change with + // the geometry or the 3D-fill toggle — never on a pan/orbit. Gating + // here on `(geometry_epoch, face3d_fill_active)` instead of inside the + // `cur_key` block (which carries `camera_generation`) stops a camera + // move from re-walking every wire to rebuild the Face3D fill buffer. + let face3d_key = (vp.geometry_epoch, face3d_fill_active); + if face3d_key != inner.cached_face3d_key { inner.upload_face3d( device, &vp.face3d_wires[..], @@ -270,7 +280,7 @@ impl shader::Primitive for Primitive { !face3d_fill_active, &vp.draw_depths, ); - inner.cached_epoch = cur_key; + inner.cached_face3d_key = face3d_key; } // Wire buffers are world-space, so a camera move alone doesn't // change them — only the view_proj uniform (uploaded every frame). @@ -298,18 +308,25 @@ impl shader::Primitive for Primitive { ); inner.cached_selection = sel_key; } - // Solid meshes carry their selection/hover tint baked into the - // vertex colours, so re-upload them when the geometry OR the - // highlight set changes. - let mesh_key = (vp.geometry_epoch, vp.selection_generation); - if mesh_key != inner.cached_mesh_key { - inner.upload_meshes( + // Batched solid meshes — geometry-only, so they ride the geometry + // epoch alone and stay resident across camera moves and selection / + // hover changes (no per-pick rebuild of the whole solid set). + if vp.geometry_epoch != inner.cached_mesh_batch_epoch { + inner.upload_mesh_batch(device, &vp.meshes[..]); + inner.cached_mesh_batch_epoch = vp.geometry_epoch; + } + // Selection / hover highlight overlay — tinted copies of just the + // picked solids, rebuilt only when the highlight set (or geometry) + // changes. Drawn over the static batch so the base never re-packs. + let hl_key = (vp.geometry_epoch, vp.selection_generation); + if hl_key != inner.cached_highlight_key { + inner.upload_mesh_highlight( device, &vp.meshes[..], &vp.selected_handles, vp.hover_handle, ); - inner.cached_mesh_key = mesh_key; + inner.cached_highlight_key = hl_key; } // Live overlay (command preview / interim / grip drag) — small and // refreshed every frame it's present, so a drag never re-uploads