From 02d9273a197a85563c2d1c3ec00a76950e6c018d Mon Sep 17 00:00:00 2001 From: Afonsop Date: Thu, 17 Sep 2026 15:26:50 +0100 Subject: [PATCH] fix(osnap): snap to 3D solids and meshes through edge wires (#1030) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Volume entities (Solid3D/Region/Body/Surface, OBJ-imported and SubD meshes, polyface meshes) render through the mesh pipeline and their wire path was deliberately empty, so the snapper had no candidates: END/MID/NEA/PER/INT never fired on them — only Insertion at the reference point. Each volume entity now contributes one interaction- only edge wire (B-rep edge chains where the entity stores them, LOD-0 triangle edges otherwise, 100k-point cap), invisible to rendering and PDF export, riding the normal wire set so the interaction index, tiers, and 2D-wins priority apply unchanged. The SubD key_vertices list no longer fabricates floating midpoints. Scene-level test drives the exact OBJ-import path (Solid3D + meshes cache) through the real snapper; INSERT-nested solids remain scoped out (block-cache path has no mesh cache yet). --- src/entities/mesh.rs | 24 ++-- src/scene/mod.rs | 59 +++++++++ src/scene/model/mesh_model.rs | 167 ++++++++++++++++++++++++ src/scene/model/solid_model.rs | 21 ++++ src/scene/view/render.rs | 4 + src/snap.rs | 223 +++++++++++++++++++++++++++++++++ 6 files changed, 488 insertions(+), 10 deletions(-) diff --git a/src/entities/mesh.rs b/src/entities/mesh.rs index e11ebed08..7bb8c367c 100644 --- a/src/entities/mesh.rs +++ b/src/entities/mesh.rs @@ -1307,24 +1307,28 @@ impl RenderConvertible for Mesh { // pick apart on screen — so past this size ship none, which is what // PolyfaceMesh / PolygonMesh already do at any size. const SNAP_TABLE_MAX_VERTICES: usize = 50_000; - let (snap_pts, key_vertices) = if self.vertices.len() > SNAP_TABLE_MAX_VERTICES { - (Vec::new(), Vec::new()) + let snap_pts = if self.vertices.len() > SNAP_TABLE_MAX_VERTICES { + Vec::new() } else { - ( - self.vertices - .iter() - .map(|v| (glam::DVec3::new(v.x, v.y, v.z), SnapHint::Node)) - .collect(), - self.vertices.iter().map(|v| [v.x, v.y, v.z]).collect(), - ) + self.vertices + .iter() + .map(|v| (glam::DVec3::new(v.x, v.y, v.z), SnapHint::Node)) + .collect() }; + // No `key_vertices`: consecutive entries there are snap-joined as + // straight segments, but a mesh's vertex list is not a chain — it + // produced midpoint candidates floating between unrelated vertices. + // Endpoint/Midpoint on the *rendered* wireframe comes from the + // interaction-only edge wire the scene appends for volume entities + // (scene `tessellate_entity`, #1030); the Node snap points above + // still land on every control vertex. Some(RenderEntity { pick_tris: Vec::new(), object: RenderObject::Lines(Vec::new()), snap_pts, tangent_geoms: vec![], - key_vertices, + key_vertices: vec![], fill_tris: vec![], }) } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 59eb787f3..a0f631e9a 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -79,6 +79,7 @@ pub(crate) fn tessellate_entity( view_aabb: Option<[f32; 4]>, world_per_pixel: Option, paper_space: bool, + volume_meshes: Option<&HashMap>, ) -> Vec { let mut wires = convert::tess::tessellate_entity( document, @@ -93,6 +94,54 @@ pub(crate) fn tessellate_entity( world_per_pixel, paper_space, ); + // Volume entities (3D solids, regions, bodies, surfaces and the mesh + // families) render through the mesh pipeline, so their wire path is + // empty (PolygonMesh keeps only its grid runs, with no per-vertex + // Endpoint or any Midpoint) — which left little for the SNAPPER to hold + // onto: no Endpoint/Midpoint on solids, no Nearest/Perpendicular/ + // Intersection on any 3D object (#1030). Complete that path with one + // interaction-only edge wire per entity, generated from the same + // tessellation the mesh pipeline draws: + // * the entity's stored B-rep edge `Wire`s when present (kernel results + // and the temporary live-section entity), then + // * the scene's `MeshLodSet` display cache (`edge_verts` feature edges + // for solids; LOD-0 triangle edges for plain meshes). + // `display_visible: false` keeps rendering single-sourced in the mesh + // pipeline; the wire rides the normal wire set so the snap engine, the + // interaction index and the incremental resident patch treat it exactly + // like any 2-D entity's geometry. + if matches!( + entity, + EntityType::Solid3D(_) + | EntityType::Region(_) + | EntityType::Body(_) + | EntityType::Surface(_) + | EntityType::Mesh(_) + | EntityType::PolygonMesh(_) + | EntityType::PolyfaceMesh(_) + ) { + let handle = entity.common().handle; + let name = handle.value().to_string(); + let color = wires + .first() + .map(|wire| wire.color) + .unwrap_or([0.8, 0.8, 0.85, 1.0]); + let stored = match entity { + EntityType::Solid3D(solid) => solid.wires.as_slice(), + EntityType::Surface(surface) => surface.wires.as_slice(), + EntityType::Region(region) => region.wires.as_slice(), + EntityType::Body(body) => body.wires.as_slice(), + _ => &[], + }; + let edge_wire = if stored.is_empty() { + volume_meshes + .and_then(|meshes| meshes.get(&handle)) + .and_then(|set| set.snap_edge_wire(name)) + } else { + model::solid_model::entity_edge_snap_wire(stored, name, color) + }; + wires.extend(edge_wire); + } if matches!(entity, EntityType::Solid3D(_) | EntityType::Surface(_)) { let handle = entity.common().handle; for source in model::solid_history::loft_visible_history_entities(document, handle) { @@ -5896,6 +5945,7 @@ impl Scene { None, None, anno_scale_override.is_some(), + Some(&self.meshes), ); memo_updates.push((*h, Arc::new(raw.clone()))); let mut faded = raw; @@ -9809,6 +9859,7 @@ impl Scene { .collect(); hits_ms = crate::perf::elapsed_ms(t_hits); let t_tess_miss = perf.then(iced::time::Instant::now); + let volume_meshes = &self.meshes; let miss_pairs: Vec<(Handle, Arc>)> = misses .par_iter() .map(|e| { @@ -9825,6 +9876,7 @@ impl Scene { view_aabb, wpp, paper, + Some(volume_meshes), ); (e.common().handle, Arc::new(w)) }) @@ -9844,6 +9896,7 @@ impl Scene { out } else { let t_tess_all = perf.then(iced::time::Instant::now); + let volume_meshes = &self.meshes; let out: Vec = visible .into_par_iter() .flat_map(|e| { @@ -9859,6 +9912,7 @@ impl Scene { view_aabb, wpp, paper, + Some(volume_meshes), ) }) .collect(); @@ -9893,6 +9947,10 @@ impl Scene { view_aabb, wpp, paper, + // The temporary sectioned entity carries its own cut + // edge `Wire`s, which the volume hook prefers over + // this (un-cut) cache. + Some(&self.meshes), )), Ok(None) => Some(Vec::new()), Err(()) => None, @@ -10808,6 +10866,7 @@ vis_index={:.1} visible_probe={:.1}", None, None, paper, + Some(&self.meshes), ) } diff --git a/src/scene/model/mesh_model.rs b/src/scene/model/mesh_model.rs index f59f91d77..e3d211081 100644 --- a/src/scene/model/mesh_model.rs +++ b/src/scene/model/mesh_model.rs @@ -3,6 +3,112 @@ // Stored alongside WireModels in the scene; rendered by the mesh pipeline // (wgpu TriangleList with depth test, flat normals). +use super::wire_model::WireModel; + +/// Hard ceiling on the point count of a generated volume snap wire, matching +/// the per-wire cap the block cache applies (`block_cache.rs`) and the spirit +/// of the mesh converters' `SNAP_TABLE_MAX_VERTICES`: a mesh dense enough to +/// exceed this has edges far too close together to pick apart at snap +/// aperture size, and duplicating them into the wire set would cost more +/// memory than the whole rest of the interaction set. +pub const SNAP_EDGE_WIRE_MAX_POINTS: usize = 100_000; + +/// Build the interaction-only edge wire shared by every volume entity +/// (3D solids, regions, bodies, surfaces, plain meshes). +/// +/// `pairs` yields one `(a, b)` world-space segment per edge. The wire carries +/// the segments as NaN-separated point pairs (so `points` never chains two +/// unrelated edges together) plus the same pairs in `key_vertices` (so +/// Endpoint/Midpoint snap sees every true edge end / edge middle, not the +/// ends of an artificial chain). It is `display_visible: false` / +/// `plot_visible: false`: the mesh pipeline already draws these edges, the +/// wire exists so the SNAPPER and the proximity pick see them exactly like +/// any 2-D entity's geometry (#1030). +/// +/// Returns `None` when the edge count exceeds [`SNAP_EDGE_WIRE_MAX_POINTS`]. +pub fn edge_snap_wire( + name: String, + color: [f32; 4], + selected: bool, + pairs: impl Iterator, +) -> Option { + let mut points: Vec<[f32; 3]> = Vec::new(); + let mut points_low: Vec<[f32; 3]> = Vec::new(); + let mut key_vertices: Vec<[f64; 3]> = Vec::new(); + let mut aabb = WireModel::UNBOUNDED_AABB; + let mut extend = |p: [f64; 3]| { + let (x, y) = (p[0] as f32, p[1] as f32); + if x.is_finite() && y.is_finite() { + if aabb == WireModel::UNBOUNDED_AABB { + aabb = [x, y, x, y]; + } else { + aabb[0] = aabb[0].min(x); + aabb[1] = aabb[1].min(y); + aabb[2] = aabb[2].max(x); + aabb[3] = aabb[3].max(y); + } + } + }; + for (a, b) in pairs { + if points.len() + 2 > SNAP_EDGE_WIRE_MAX_POINTS { + return None; + } + if !points.is_empty() { + points.push([f32::NAN; 3]); + points_low.push([0.0; 3]); + if points.len() + 2 > SNAP_EDGE_WIRE_MAX_POINTS { + return None; + } + } + for p in [a, b] { + let (hx, lx) = WireModel::split_ds(p[0]); + let (hy, ly) = WireModel::split_ds(p[1]); + let (hz, lz) = WireModel::split_ds(p[2]); + points.push([hx, hy, hz]); + points_low.push([lx, ly, lz]); + key_vertices.push(p); + extend(p); + } + } + if points.is_empty() { + return None; + } + Some(WireModel { + bg_adapt: None, + point_marker: None, + taper_widths: Vec::new(), + pattern_stations: Vec::new(), + world_width: 0.0, + depth_override: None, + display_visible: false, + plot_visible: false, + fill_is_3d: false, + fill_is_2d_solid: false, + render_instance: None, + pick_tris: Vec::new(), + pick_tris_low: Vec::new(), + text_verts: Vec::new(), + name, + points, + points_low, + color, + selected, + aci: 0, + pattern_length: 0.0, + pattern: [0.0; 8], + line_weight_px: 1.0, + snap_pts: vec![], + tangent_geoms: vec![], + key_vertices, + aabb, + plinegen: true, + dash_from_start: false, + dash_align_end: None, + fill_tris: vec![], + fill_tris_low: Vec::new(), + }) +} + /// A tessellated triangle mesh ready to upload to the GPU. #[derive(Clone, Debug)] #[allow(dead_code)] @@ -317,4 +423,65 @@ impl MeshLodSet { .map(|mesh| mesh.color) }) } + + /// Interaction-only edge wire for this volume entity (#1030). + /// + /// ACIS solids carry their B-rep feature edges in `edge_verts` (a flat + /// line list of endpoint pairs). Plain meshes (OBJ imports, SubD meshes, + /// polyface meshes) leave it empty — their visible wireframe is the + /// triangle edges of the highest-detail LOD, exactly what the mesh GPU + /// pipeline draws when `edge_verts` is empty — so derive the pairs from + /// LOD 0's index buffer the same way. Instanced (block-local) sources are + /// resolved through `geometry_lods` / `geometry_edges`; the caller applies + /// the INSERT transform when it expands the block, like any other wire. + pub fn snap_edge_wire(&self, name: String) -> Option { + let color = self.display_color().unwrap_or([0.8, 0.8, 0.85, 1.0]); + let (edges, edges_low) = self.geometry_edges(); + if !edges.is_empty() { + let at = |index: usize| -> [f64; 3] { + let high = edges[index]; + let low = edges_low.get(index).copied().unwrap_or([0.0; 3]); + [ + high[0] as f64 + low[0] as f64, + high[1] as f64 + low[1] as f64, + high[2] as f64 + low[2] as f64, + ] + }; + return edge_snap_wire( + name, + color, + false, + (0..edges.len() / 2).map(|pair| { + let base = pair * 2; + (at(base), at(base + 1)) + }), + ); + } + let mesh = self + .geometry_lods() + .iter() + .find(|lod| !lod.indices.is_empty())?; + let verts = &mesh.verts; + let at = |index: usize| -> Option<[f64; 3]> { + let high = verts.get(index)?; + let low = mesh.verts_low.get(index).copied().unwrap_or([0.0; 3]); + Some([ + high[0] as f64 + low[0] as f64, + high[1] as f64 + low[1] as f64, + high[2] as f64 + low[2] as f64, + ]) + }; + let pairs = mesh + .indices + .chunks_exact(3) + .filter_map(|triangle| { + Some(( + at(triangle[0] as usize)?, + at(triangle[1] as usize)?, + at(triangle[2] as usize)?, + )) + }) + .flat_map(|(a, b, c)| [(a, b), (b, c), (c, a)]); + edge_snap_wire(name, color, false, pairs) + } } diff --git a/src/scene/model/solid_model.rs b/src/scene/model/solid_model.rs index 1fa867915..1dd9de497 100644 --- a/src/scene/model/solid_model.rs +++ b/src/scene/model/solid_model.rs @@ -269,6 +269,27 @@ pub fn edge_wires(body: &Body) -> Vec { .collect() } +/// Interaction-only snap wire for a volume entity's stored edge `Wire`s +/// (`Solid3D::wires` and friends — pre-computed B-rep edge chains, also set +/// on the temporary live-section entity). Each chain contributes its +/// consecutive point pairs as snap segments (#1030). Returns `None` when the +/// chains carry no usable segments or exceed the shared snap-wire point cap. +pub fn entity_edge_snap_wire( + chains: &[acadrust::entities::Wire], + name: String, + color: [f32; 4], +) -> Option { + let pairs = chains.iter().flat_map(|chain| { + chain.points.windows(2).map(|segment| { + ( + [segment[0].x, segment[0].y, segment[0].z], + [segment[1].x, segment[1].y, segment[1].z], + ) + }) + }); + super::mesh_model::edge_snap_wire(name, color, false, pairs) +} + /// White wireframe used while a solid-history grip is hot. Surface previews /// include the requested construction isolines so their cage follows the edit. /// diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 82bfd2eb7..1a977220b 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -3914,6 +3914,10 @@ impl Scene { None, None, content_viewport, + // Alternate-scale context previews of annotative entities + // (text/dims/leaders) — volume entities are never + // annotative, so no edge wire is needed here. + None, ); for wire in &mut context_wires { wire.color = tint; diff --git a/src/snap.rs b/src/snap.rs index 0d4460d06..51d94868b 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -4130,4 +4130,227 @@ mod ext_tests { } } + // ── Volume entities (3D solids / meshes) — #1030 ───────────────────────── + // + // Solids and meshes render through the mesh pipeline, so their wire path + // used to be empty: no Endpoint/Midpoint/Nearest snap on any 3D object. + // The scene now appends one interaction-only edge wire per volume entity + // (see `scene::tessellate_entity`); these tests pin the snap coverage that + // wire provides, at the same seams the production code uses. + + /// Two-triangle square in the z=0 plane, exactly what an OBJ import or a + /// plain polyface mesh produces. + fn square_mesh_lod_set(name: &str) -> crate::scene::model::mesh_model::MeshLodSet { + use crate::scene::model::mesh_model::{MeshLodSet, MeshModel}; + MeshLodSet::from_single(MeshModel { + name: name.to_owned(), + verts: vec![ + [0.0, 0.0, 0.0], + [10.0, 0.0, 0.0], + [10.0, 10.0, 0.0], + [0.0, 10.0, 0.0], + ], + verts_low: Vec::new(), + normals: Vec::new(), + indices: vec![0, 1, 2, 0, 2, 3], + triangle_material_handles: Vec::new(), + triangle_colors: Vec::new(), + color: [0.7, 0.7, 0.85, 1.0], + selected: false, + }) + } + + fn snap_one( + snapper: &mut Snapper, + wires: &[WireModel], + near: DVec3, + expected: SnapType, + want: DVec3, + ) { + // Zoomed out: 1 world unit ≈ 5 px, so sub-unit cursor nudges stay + // inside the 15 px aperture. + let view_rot = Mat4::from_scale(Vec3::splat(0.01)); + let bounds = Rectangle { + x: 0.0, + y: 0.0, + width: 1000.0, + height: 1000.0, + }; + let eye = DVec3::new(want.x, want.y, 500.0); + let cursor_screen = world_to_screen(near, view_rot, eye, bounds); + let res = snapper + .snap( + near, cursor_screen, wires, view_rot, eye, bounds, Vec3::ZERO, + (Vec3::X, Vec3::Y, Vec3::Z), None, + ) + .unwrap_or_else(|| panic!("no {expected:?} snap near {near:?}")); + assert_eq!(res.snap_type, expected, "near {near:?}"); + let err = (res.world - want).length(); + assert!(err < 1e-6, "{expected:?} snap landed {err} from {want:?}"); + } + + #[test] + fn mesh_edge_wire_snaps_endpoint_midpoint_and_nearest() { + let wire = square_mesh_lod_set("1") + .snap_edge_wire("1".to_owned()) + .expect("plain mesh should derive an edge wire from LOD-0 triangles"); + // The generated wire must take part in interaction only — rendering + // stays single-sourced in the mesh pipeline. + assert!(!wire.display_visible); + assert!(!wire.plot_visible); + assert!(!wire.points.is_empty()); + + let wires = [wire]; + let mut snapper = Snapper::default(); + snapper.snap_enabled = true; + + snapper.enabled = [SnapType::Endpoint].into_iter().collect(); + snap_one( + &mut snapper, + &wires, + DVec3::new(0.2, 0.1, 0.0), + SnapType::Endpoint, + DVec3::new(0.0, 0.0, 0.0), + ); + snap_one( + &mut snapper, + &wires, + DVec3::new(10.2, 9.9, 0.0), + SnapType::Endpoint, + DVec3::new(10.0, 10.0, 0.0), + ); + + snapper.enabled = [SnapType::Midpoint].into_iter().collect(); + snap_one( + &mut snapper, + &wires, + DVec3::new(5.1, 0.2, 0.0), + SnapType::Midpoint, + DVec3::new(5.0, 0.0, 0.0), + ); + + snapper.enabled = [SnapType::Nearest].into_iter().collect(); + snap_one( + &mut snapper, + &wires, + DVec3::new(12.0, 5.0, 0.0), + SnapType::Nearest, + DVec3::new(10.0, 5.0, 0.0), + ); + } + + #[test] + fn solid_stored_edge_wires_snap_endpoint_and_midpoint() { + use crate::scene::model::solid_model::entity_edge_snap_wire; + // A box's bottom + vertical edges as stored B-rep chains, the same + // shape `register_prepared_solid_model` writes into `Solid3D::wires`. + let chain = |points: &[[f64; 3]]| { + acadrust::entities::Wire::from_points( + points + .iter() + .map(|p| acadrust::types::Vector3::new(p[0], p[1], p[2])) + .collect(), + ) + }; + let chains = vec![ + chain(&[[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [10.0, 10.0, 0.0], [0.0, 10.0, 0.0]]), + chain(&[[0.0, 0.0, 4.0], [10.0, 0.0, 4.0], [10.0, 10.0, 4.0], [0.0, 10.0, 4.0]]), + chain(&[[0.0, 0.0, 0.0], [0.0, 0.0, 4.0]]), + ]; + let wire = + entity_edge_snap_wire(&chains, "42".to_owned(), [0.8, 0.8, 0.85, 1.0]).expect("wire"); + let wires = [wire]; + let mut snapper = Snapper::default(); + snapper.snap_enabled = true; + + snapper.enabled = [SnapType::Endpoint].into_iter().collect(); + snap_one( + &mut snapper, + &wires, + DVec3::new(9.8, 10.1, 0.2), + SnapType::Endpoint, + DVec3::new(10.0, 10.0, 0.0), + ); + + snapper.enabled = [SnapType::Midpoint].into_iter().collect(); + // Midpoint of a vertical solid edge — a true 3-D snap point. + snap_one( + &mut snapper, + &wires, + DVec3::new(0.1, 0.1, 2.1), + SnapType::Midpoint, + DVec3::new(0.0, 0.0, 2.0), + ); + } + + #[test] + fn edge_snap_wire_caps_runaway_meshes() { + use crate::scene::model::mesh_model::{edge_snap_wire, SNAP_EDGE_WIRE_MAX_POINTS}; + let segment = ([0.0, 0.0, 0.0], [1.0, 0.0, 0.0]); + // Each pair costs up to 3 points (2 endpoints + a NaN separator), so + // a third of the cap fits and the full cap must not. + let pairs = std::iter::repeat(segment).take(SNAP_EDGE_WIRE_MAX_POINTS / 3); + assert!(edge_snap_wire("ok".into(), [1.0; 4], false, pairs).is_some()); + let pairs = std::iter::repeat(segment).take(SNAP_EDGE_WIRE_MAX_POINTS); + assert!( + edge_snap_wire("too big".into(), [1.0; 4], false, pairs).is_none(), + "meshes past the point cap must ship no snap wire" + ); + } + + #[test] + fn scene_solid_with_mesh_cache_snaps_endpoint_midpoint_nearest() { + use crate::scene::model::mesh_model::MeshLodSet; + use crate::scene::Scene; + use acadrust::entities::Solid3D; + + let mut scene = Scene::new(); + let handle = scene.add_entity(acadrust::EntityType::Solid3D(Solid3D::new())); + assert!(!handle.is_null()); + // Mirror the OBJ import: an (empty) Solid3D entity plus a display + // mesh in the scene cache. + scene + .meshes + .insert(handle, square_mesh_lod_set(&handle.value().to_string())); + + let wires = scene.hit_test_wires(); + let edge_wire = wires + .iter() + .find(|wire| wire.name == handle.value().to_string() && !wire.points.is_empty()) + .expect("the solid's interaction edge wire must join the snap wire set"); + assert!(!edge_wire.display_visible); + + let owned = wires.as_ref().clone(); + let wires = owned.as_slice(); + let mut snapper = Snapper::default(); + snapper.snap_enabled = true; + + snapper.enabled = [SnapType::Endpoint].into_iter().collect(); + snap_one( + &mut snapper, + wires, + DVec3::new(0.1, 0.2, 0.0), + SnapType::Endpoint, + DVec3::new(0.0, 0.0, 0.0), + ); + + snapper.enabled = [SnapType::Midpoint].into_iter().collect(); + snap_one( + &mut snapper, + wires, + DVec3::new(5.1, 9.9, 0.0), + SnapType::Midpoint, + DVec3::new(5.0, 10.0, 0.0), + ); + + snapper.enabled = [SnapType::Nearest].into_iter().collect(); + snap_one( + &mut snapper, + wires, + DVec3::new(-2.0, 5.0, 0.0), + SnapType::Nearest, + DVec3::new(0.0, 5.0, 0.0), + ); + } + }