From ee89458f8af1ac4aa21c834c0dfb67b89c025494 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 21 Aug 2026 19:47:03 +0300 Subject: [PATCH] fix(solid): use kernel geometry Derive extrusion, bounds, edges, and fill from cadkernel while preserving precise view filtering and stable GPU uploads. --- src/entities/solid.rs | 232 +++++++++++++++++++++---------- src/scene/convert/tess.rs | 27 +++- src/scene/pipeline/face3d_gpu.rs | 44 ++++-- src/scene/pipeline/mod.rs | 10 +- src/scene/view/render.rs | 16 +-- 5 files changed, 225 insertions(+), 104 deletions(-) diff --git a/src/entities/solid.rs b/src/entities/solid.rs index 3541b9c42..bb4972f51 100644 --- a/src/entities/solid.rs +++ b/src/entities/solid.rs @@ -1,12 +1,4 @@ -// SOLID entity — 2D filled quadrilateral (or triangle when p3 == p4). -// -// Wireframe: the 4 perimeter edges as RenderObject::Lines. -// Filled: boundary triangles on `fill_tris`, plus the top and side faces for -// non-zero thickness. The full WCS plane is preserved both at top -// level and through block expansion. The scene keeps a separate 2-D -// HatchModel only for plot projection; screen rendering filters that -// flattened copy out. -// Grips: 4 corner grip points. +// SOLID geometry comes from the kernel; entity coordinates stay in WCS here. use acadrust::entities::Solid; use crate::t; @@ -18,6 +10,14 @@ use crate::scene::convert::acad_to_render::{RenderEntity, RenderObject}; use crate::scene::model::object::{GripApply, GripDef, PropSection}; use crate::scene::model::wire_model::SnapHint; +type Edge = ([f64; 3], [f64; 3]); + +struct SolidGeometry { + edges: Vec, + fill_tris: Vec<[f64; 3]>, + extruded: bool, +} + fn normal_tuple(solid: &Solid) -> (f64, f64, f64) { let normal = glam::DVec3::new(solid.normal.x, solid.normal.y, solid.normal.z) .normalize_or(glam::DVec3::Z); @@ -64,87 +64,171 @@ fn push_edge(lines: &mut Vec<[f64; 3]>, start: [f64; 3], end: [f64; 3]) { lines.extend([start, end, [f64::NAN; 3]]); } -fn push_fan(triangles: &mut Vec<[f64; 3]>, points: &[[f64; 3]], reverse: bool) { - for index in 1..points.len() - 1 { - if reverse { - triangles.extend([points[0], points[index + 1], points[index]]); - } else { - triangles.extend([points[0], points[index], points[index + 1]]); - } +fn boundary(solid: &Solid) -> Vec<[f64; 3]> { + let corners = wcs_corners(solid); + if solid.is_triangle() { + vec![corners[0], corners[1], corners[2]] + } else { + vec![corners[0], corners[1], corners[3], corners[2]] } } +fn boundary_edges(points: &[[f64; 3]]) -> Vec { + (0..points.len()) + .map(|index| (points[index], points[(index + 1) % points.len()])) + .collect() +} + +fn kernel_tolerance(points: &[[f64; 3]]) -> f64 { + cadkernel::space::coplanarity_tolerance(points).max(1.0e-12) +} + +fn extrusion_body( + solid: &Solid, + base: &[[f64; 3]], + tolerance: f64, + profile_valid: bool, +) -> Option { + if solid.thickness.abs() <= 1.0e-10 || !profile_valid { + return None; + } + let normal = glam::DVec3::new(solid.normal.x, solid.normal.y, solid.normal.z) + .normalize_or(glam::DVec3::Z); + let origin = glam::DVec3::from_array(base[0]); + let axis = base + .iter() + .skip(1) + .map(|point| glam::DVec3::from_array(*point) - origin) + .find(|axis| axis.length() > tolerance)?; + let plane = cadkernel::space::Plane::orthonormal( + base[0], + axis.to_array(), + normal.to_array(), + )?; + if base + .iter() + .any(|point| !plane.contains(*point, tolerance)) + { + return None; + } + let projected = base + .iter() + .map(|point| plane.project(*point)) + .collect::>>()?; + let profile = (0..projected.len()) + .map(|index| { + cadkernel::geom2d::Curve::Line(cadkernel::geom2d::Line { + start: projected[index], + end: projected[(index + 1) % projected.len()], + }) + }) + .collect::>(); + cadkernel::brep::extrude( + plane, + &profile, + (normal * solid.thickness).to_array(), + ) +} + +fn solid_geometry(solid: &Solid) -> Option { + let base = boundary(solid); + let tolerance = kernel_tolerance(&base); + let fill_tris = cadkernel::space::polygon::triangulate( + &base, + cadkernel::geom2d::Tolerance::new(tolerance), + ); + if solid.thickness.abs() <= 1.0e-10 { + return Some(SolidGeometry { + edges: boundary_edges(&base), + fill_tris, + extruded: false, + }); + } + let Some(body) = extrusion_body(solid, &base, tolerance, !fill_tris.is_empty()) else { + return Some(SolidGeometry { + edges: boundary_edges(&base), + fill_tris: Vec::new(), + extruded: false, + }); + }; + let mesh = cadkernel::brep::mesh::tessellate( + &body, + cadkernel::brep::mesh::TessellationTolerance::new( + cadkernel::tessellation::DEFAULT_ANGLE, + tolerance, + ), + ); + if !mesh.missing_faces.is_empty() || mesh.mesh.is_empty() { + return Some(SolidGeometry { + edges: boundary_edges(&base), + fill_tris: Vec::new(), + extruded: false, + }); + } + let edges = mesh + .edges + .iter() + .flat_map(|edge| edge.positions.windows(2).map(|pair| (pair[0], pair[1]))) + .collect(); + let mut fill_tris = Vec::with_capacity(mesh.mesh.triangles.len() * 3); + for triangle in mesh.mesh.triangles { + fill_tris.extend(triangle.map(|index| mesh.mesh.positions[index])); + } + Some(SolidGeometry { + edges, + fill_tris, + extruded: true, + }) +} + +pub(crate) fn wcs_bounds(solid: &Solid) -> Option { + let base = boundary(solid); + let tolerance = kernel_tolerance(&base); + let profile_valid = !cadkernel::space::polygon::triangulate( + &base, + cadkernel::geom2d::Tolerance::new(tolerance), + ) + .is_empty(); + extrusion_body(solid, &base, tolerance, profile_valid) + .and_then(|body| cadkernel::brep::body_bounds(&body)) + .or_else(|| cadkernel::brep::Aabb::around(base)) +} + impl RenderConvertible for Solid { fn to_render(&self, _document: &acadrust::CadDocument) -> Option { - // SOLID corners are OCS and the last two are stored in Z order. Preserve - // that ordering exactly: it is part of the entity geometry and can - // intentionally describe a crossing shape. - let corners = wcs_corners(self); - let base = if self.is_triangle() { - vec![corners[0], corners[1], corners[2]] - } else { - vec![corners[0], corners[1], corners[3], corners[2]] - }; - let normal = glam::DVec3::new(self.normal.x, self.normal.y, self.normal.z) - .normalize_or(glam::DVec3::Z); - let extruded = self.thickness.abs() > 1.0e-10; - let top: Vec<[f64; 3]> = base - .iter() - .map(|point| { - (glam::DVec3::from_array(*point) + normal * self.thickness).to_array() - }) - .collect(); - + let geometry = solid_geometry(self)?; let mut lines = Vec::new(); - for index in 0..base.len() { - push_edge(&mut lines, base[index], base[(index + 1) % base.len()]); + for &(start, end) in &geometry.edges { + push_edge(&mut lines, start, end); } - if extruded { - for index in 0..top.len() { - push_edge(&mut lines, top[index], top[(index + 1) % top.len()]); - push_edge(&mut lines, base[index], top[index]); + let mut nodes = Vec::new(); + for &(start, end) in &geometry.edges { + for point in [start, end] { + if !nodes.contains(&point) { + nodes.push(point); + } } } - - let mut fill_tris = Vec::new(); - push_fan(&mut fill_tris, &base, false); - if extruded { - push_fan(&mut fill_tris, &top, true); - for index in 0..base.len() { - let next = (index + 1) % base.len(); - fill_tris.extend([ - base[index], - base[next], - top[next], - base[index], - top[next], - top[index], - ]); - } + let mut snap = Vec::with_capacity(nodes.len() * 2 + geometry.edges.len()); + for point in nodes { + snap.push((dvec3(point), SnapHint::Node)); + snap.push((dvec3(point), SnapHint::Endpoint)); } - - let mut snap_points = base.clone(); - if extruded { - snap_points.extend(top.iter().copied()); - } - let snap = snap_points - .iter() - .copied() - .map(|point| (dvec3(point), SnapHint::Node)) - .collect(); - let pick_tris = if extruded { - fill_tris.clone() - } else { - Vec::new() - }; + snap.extend(geometry.edges.iter().map(|(start, end)| { + ((dvec3(*start) + dvec3(*end)) * 0.5, SnapHint::Midpoint) + })); + let pick_tris = geometry + .extruded + .then(|| geometry.fill_tris.clone()) + .unwrap_or_default(); Some(RenderEntity { pick_tris, object: RenderObject::Lines(lines), snap_pts: snap, tangent_geoms: vec![], - key_vertices: snap_points, - fill_tris, + key_vertices: Vec::new(), + fill_tris: geometry.fill_tris, }) } } diff --git a/src/scene/convert/tess.rs b/src/scene/convert/tess.rs index 71a60a6ac..55a3fe84d 100644 --- a/src/scene/convert/tess.rs +++ b/src/scene/convert/tess.rs @@ -1657,12 +1657,25 @@ pub(crate) fn set_wire_aabb(w: &mut WireModel, entity_box: [f32; 4]) { w.aabb = out; } +fn entity_bounds(e: &acadrust::EntityType) -> ([f64; 3], [f64; 3]) { + if let acadrust::EntityType::Solid(solid) = e { + if let Some(bounds) = crate::entities::solid::wcs_bounds(solid) { + return (bounds.min, bounds.max); + } + } + let bounds = e.as_entity().bounding_box(); + ( + [bounds.min.x, bounds.min.y, bounds.min.z], + [bounds.max.x, bounds.max.y, bounds.max.z], + ) +} + pub(crate) fn entity_aabb(e: &acadrust::EntityType) -> [f32; 4] { - let bbox = e.as_entity().bounding_box(); - let min_x = (bbox.min.x) as f32; - let min_y = (bbox.min.y) as f32; - let max_x = (bbox.max.x) as f32; - let max_y = (bbox.max.y) as f32; + let (min, max) = entity_bounds(e); + let min_x = min[0] as f32; + let min_y = min[1] as f32; + let max_x = max[0] as f32; + let max_y = max[1] as f32; // The all-zero box is bounding_box()'s Default — returned by entities with // no usable box (unimplemented) — so treat it as UNBOUNDED (never // pre-rejected). A genuinely zero-size box *away* from the origin (e.g. a @@ -1680,8 +1693,8 @@ pub(crate) fn entity_aabb(e: &acadrust::EntityType) -> [f32; 4] { /// indexing uses this so changing `world_offset` doesn't invalidate /// the index. pub(crate) fn entity_world_aabb_f64(e: &acadrust::EntityType) -> Option<[f64; 4]> { - let bbox = e.as_entity().bounding_box(); - let (xmin, ymin, xmax, ymax) = (bbox.min.x, bbox.min.y, bbox.max.x, bbox.max.y); + let (min, max) = entity_bounds(e); + let (xmin, ymin, xmax, ymax) = (min[0], min[1], max[0], max[1]); if xmin == xmax && ymin == ymax { return None; } diff --git a/src/scene/pipeline/face3d_gpu.rs b/src/scene/pipeline/face3d_gpu.rs index 30ad9d3b2..929d83d73 100644 --- a/src/scene/pipeline/face3d_gpu.rs +++ b/src/scene/pipeline/face3d_gpu.rs @@ -22,20 +22,48 @@ use crate::scene::model::wire_model::WireModel; use iced::wgpu; use iced::wgpu::util::DeviceExt; -fn planar_solid_faces_view(wire: &WireModel, view_dir: glam::Vec3) -> bool { +pub(crate) fn planar_solid_faces_view(wire: &WireModel, view_dir: glam::Vec3) -> bool { if !wire.fill_is_2d_solid || wire.fill_is_3d { return true; } - let view = view_dir.normalize_or_zero(); - wire.fill_tris.chunks_exact(3).any(|triangle| { - let first = glam::Vec3::from_array(triangle[0]); - let second = glam::Vec3::from_array(triangle[1]); - let third = glam::Vec3::from_array(triangle[2]); + let view = view_dir.as_dvec3().normalize_or_zero(); + let point = |index: usize| { + let high = wire.fill_tris[index]; + let low = wire + .fill_tris_low + .get(index) + .copied() + .unwrap_or([0.0; 3]); + glam::DVec3::new( + high[0] as f64 + low[0] as f64, + high[1] as f64 + low[1] as f64, + high[2] as f64 + low[2] as f64, + ) + }; + (0..wire.fill_tris.len() / 3).any(|triangle| { + let first = point(triangle * 3); + let second = point(triangle * 3 + 1); + let third = point(triangle * 3 + 2); let normal = (second - first).cross(third - first).normalize_or_zero(); normal.length_squared() > 0.0 && normal.dot(view).abs() >= 1.0 - 1.0e-5 }) } +pub(crate) fn planar_solid_visibility_key( + wires: &[WireModel], + view_dir: glam::Vec3, +) -> u64 { + let mut key = 0xcbf29ce484222325_u64; + for wire in wires + .iter() + .filter(|wire| wire.fill_is_2d_solid && !wire.fill_is_3d) + { + key ^= planar_solid_faces_view(wire, view_dir) as u64; + key = key.wrapping_mul(0x100000001b3); + } + key +} + // ── Vertex layout ────────────────────────────────────────────────────────── #[repr(C)] @@ -88,8 +116,8 @@ impl Face3DVertex { // ── GPU handle ───────────────────────────────────────────────────────────── /// One vertex buffer + draw count. Fill data is split into as many chunks -/// as the device's `max_buffer_size` requires: a mesh-heavy DWG (e.g. a -/// Navisworks import) can hold tens of millions of fill triangles, and at +/// as the device's `max_buffer_size` requires: a mesh-heavy drawing can hold +/// tens of millions of fill triangles, and at /// 44 B/vertex a single batched buffer blows past the default 256 MB limit /// once enough layers are thawed — wgpu then raises an uncaptured /// validation error and aborts the process (#358). Same scheme as the diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 247043f87..b6013f888 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -312,12 +312,8 @@ pub struct Pipeline { /// a pick bumps only `selection_generation`, refreshing the overlay without /// touching the main wire buffers. pub cached_selection: (u64, u64), - /// `(wire_content_id, face3d_fill_active, show_2d_solid_fills, view_dir)` the Face3D - /// edge/fill buffers were uploaded for. A stable content id avoids retaining - /// the resident wire Arc: - /// that Arc must stay uniquely owned by Scene so a small edit can splice it - /// in place instead of rebuilding the whole drawing. - pub cached_face3d_key: (u64, bool, bool, [u32; 3]), + /// Face upload key; keeps the resident wire Arc uniquely owned by Scene. + pub cached_face3d_key: (u64, bool, bool, u64), /// 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 @@ -2323,7 +2319,7 @@ impl Pipeline { cached_epoch: (u64::MAX, u64::MAX, u64::MAX), cached_wire_id: u64::MAX, cached_selection: (u64::MAX, u64::MAX), - cached_face3d_key: (u64::MAX, false, false, [u32::MAX; 3]), + cached_face3d_key: (u64::MAX, false, false, u64::MAX), wire_handle_index: std::sync::Arc::new(rustc_hash::FxHashMap::default()), render_sig: u64::MAX, skip_geometry: false, diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 20a30a3bc..cb96d6ea9 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -378,7 +378,7 @@ impl shader::Primitive for Primitive { inner.cached_wire_id = u64::MAX; inner.cached_selection = (u64::MAX, u64::MAX); inner.cached_mesh_content_id = u64::MAX; - inner.cached_face3d_key = (u64::MAX, false, false, [u32::MAX; 3]); + inner.cached_face3d_key = (u64::MAX, false, false, u64::MAX); inner.cached_hatch_source = None; inner.cached_preview_hatch_source = None; inner.cached_wipeout_source = None; @@ -485,11 +485,11 @@ impl shader::Primitive for Primitive { // the Wireframe overlay style. let face3d_fill_active = fill_mode && !vp.view_wireframe; let solid_fill_active = fill_mode && vp.show_2d_solid_fills; - let view_dir_key = [ - vp.view_dir.x.to_bits(), - vp.view_dir.y.to_bits(), - vp.view_dir.z.to_bits(), - ]; + let solid_visibility_key = + crate::scene::pipeline::face3d_gpu::planar_solid_visibility_key( + &vp_wires, + vp.view_dir, + ); let fill_changed = inner.cached_fill_mode != fill_mode; let hatch_changed = inner .cached_hatch_source @@ -581,7 +581,7 @@ impl shader::Primitive for Primitive { if face3d_changed || face3d_fill_active != inner.cached_face3d_key.1 || solid_fill_active != inner.cached_face3d_key.2 - || view_dir_key != inner.cached_face3d_key.3 + || solid_visibility_key != inner.cached_face3d_key.3 { inner.upload_face3d( device, @@ -599,7 +599,7 @@ impl shader::Primitive for Primitive { vp.wire_content_id, face3d_fill_active, solid_fill_active, - view_dir_key, + solid_visibility_key, ); // Wire buffers are world-space, so a camera move alone doesn't // change them — only the view_proj uniform (uploaded every frame).