diff --git a/src/app/cmd_result.rs b/src/app/cmd_result.rs index 06b9c17e8..c046c2907 100644 --- a/src/app/cmd_result.rs +++ b/src/app/cmd_result.rs @@ -77,6 +77,13 @@ impl OpenCADStudio { let label = self.history_label_from_active_cmd(i, "MOVE"); self.push_undo_snapshot(i, label); self.tabs[i].scene.transform_entities(&handles, &transform); + // ACIS solids render from a cached mesh, so a move/rotate/ + // scale/mirror needs the mesh re-tessellated from the now-moved + // body — wire re-tessellation alone leaves the solid drawn at + // its old spot. (#135) + if self.tabs[i].scene.any_solid(&handles) { + self.tabs[i].scene.populate_meshes_from_document(); + } self.tabs[i].dirty = true; self.tabs[i].scene.clear_preview_wire(); self.tabs[i].active_cmd = None; @@ -88,6 +95,9 @@ impl OpenCADStudio { let label = self.history_label_from_active_cmd(i, "COPY"); self.push_undo_snapshot(i, label); let new_handles = self.tabs[i].scene.copy_entities(&handles, &transform); + if self.tabs[i].scene.any_solid(&new_handles) { + self.tabs[i].scene.populate_meshes_from_document(); + } self.tabs[i].dirty = true; self.tabs[i].scene.deselect_all(); for h in new_handles { diff --git a/src/app/update.rs b/src/app/update.rs index 8836f6723..cb3ebde16 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -3739,6 +3739,9 @@ impl OpenCADStudio { vp_mat, bounds, )); + handles.extend( + self.tabs[i].scene.mesh_box_hit(a, p, crossing, vp_mat, bounds), + ); // Box/lasso accumulates like individual picks // (issue #83): a plain box adds to the current // selection, Shift+box removes the boxed @@ -3790,6 +3793,9 @@ impl OpenCADStudio { vp_mat, bounds, )); + handles.extend( + self.tabs[i].scene.mesh_poly_hit(&poly_pts, crossing, vp_mat, bounds), + ); // Selection filter: keep only allowed types. handles.retain(|&h| self.tabs[i].scene.passes_selection_filter(h)); // Accumulate like the box path (issue #83): plain @@ -3926,6 +3932,9 @@ impl OpenCADStudio { vp_mat, bounds, )); + handles.extend( + self.tabs[i].scene.mesh_box_hit(a, p, crossing, vp_mat, bounds), + ); // Selection filter: keep only allowed types. handles.retain(|&h| self.tabs[i].scene.passes_selection_filter(h)); // Accumulate (issue #83): a plain box adds to the diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 7e7780a50..918df64df 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -2264,6 +2264,52 @@ impl Scene { pick::hit_test::mesh_click_hit(cursor, iter, view_proj, bounds) } + /// True when any handle resolves to an ACIS volume entity (3D solid / + /// region / body / surface) — i.e. one whose render geometry is a cached + /// mesh that must be re-tessellated after an edit. + pub fn any_solid(&self, handles: &[Handle]) -> bool { + handles.iter().any(|&h| { + matches!( + self.document.get_entity(h), + Some(EntityType::Solid3D(_)) + | Some(EntityType::Region(_)) + | Some(EntityType::Body(_)) + | Some(EntityType::Surface(_)) + ) + }) + } + + /// Top-level solid handles caught by a rectangular selection box. + pub fn mesh_box_hit( + &self, + a: iced::Point, + b: iced::Point, + crossing: bool, + view_proj: glam::Mat4, + bounds: iced::Rectangle, + ) -> Vec { + let iter = self + .meshes + .iter() + .filter_map(|(h, set)| set.lods.first().map(|m| (*h, m))); + pick::hit_test::mesh_box_hit(a, b, crossing, iter, view_proj, bounds) + } + + /// Top-level solid handles caught by a lasso polygon. + pub fn mesh_poly_hit( + &self, + poly: &[iced::Point], + crossing: bool, + view_proj: glam::Mat4, + bounds: iced::Rectangle, + ) -> Vec { + let iter = self + .meshes + .iter() + .filter_map(|(h, set)| set.lods.first().map(|m| (*h, m))); + pick::hit_test::mesh_poly_hit(poly, crossing, iter, view_proj, bounds) + } + /// Tessellate all non-invisible entities owned by `block_handle`. fn wires_for_block(&self, block_handle: Handle) -> Vec { // Default culling is driven by the live `Scene::camera`. Multi-tile diff --git a/src/scene/pick/hit_test.rs b/src/scene/pick/hit_test.rs index 6e9fcc985..67aae15c1 100644 --- a/src/scene/pick/hit_test.rs +++ b/src/scene/pick/hit_test.rs @@ -168,6 +168,108 @@ pub fn mesh_click_hit<'a>( best.map(|(_, h)| h) } +/// Project a mesh's vertices to screen space. +fn project_mesh_verts(mesh: &MeshModel, view_proj: Mat4, bounds: Rectangle) -> Vec { + mesh.verts + .iter() + .map(|w| { + let ndc = view_proj.project_point3(Vec3::new(w[0], w[1], w[2])); + Point::new( + (ndc.x + 1.0) * 0.5 * bounds.width, + (1.0 - ndc.y) * 0.5 * bounds.height, + ) + }) + .collect() +} + +/// True when any of `mesh`'s projected triangles contains one of `pts` +/// (used so a crossing box / lasso entirely inside a solid still selects it). +fn mesh_covers_any(proj: &[Point], indices: &[u32], pts: &[Point]) -> bool { + let mut t = 0; + while t + 2 < indices.len() { + let tri = [ + proj[indices[t] as usize], + proj[indices[t + 1] as usize], + proj[indices[t + 2] as usize], + ]; + t += 3; + if pts.iter().any(|p| point_in_polygon(*p, &tri)) { + return true; + } + } + false +} + +/// Solid (mesh) handles caught by a rectangular selection box. Window mode +/// (`crossing == false`) needs every projected vertex inside the box; +/// crossing mode needs any vertex inside, or the box to sit inside the solid. +pub fn mesh_box_hit<'a>( + a: Point, + b: Point, + crossing: bool, + meshes: impl Iterator, + view_proj: Mat4, + bounds: Rectangle, +) -> Vec { + let (min_x, max_x) = (a.x.min(b.x), a.x.max(b.x)); + let (min_y, max_y) = (a.y.min(b.y), a.y.max(b.y)); + let in_box = |p: &Point| p.x >= min_x && p.x <= max_x && p.y >= min_y && p.y <= max_y; + let corners = [ + Point::new(min_x, min_y), + Point::new(max_x, min_y), + Point::new(max_x, max_y), + Point::new(min_x, max_y), + ]; + let mut out = Vec::new(); + for (h, mesh) in meshes { + let proj = project_mesh_verts(mesh, view_proj, bounds); + if proj.is_empty() { + continue; + } + let hit = if crossing { + proj.iter().any(in_box) || mesh_covers_any(&proj, &mesh.indices, &corners) + } else { + proj.iter().all(in_box) + }; + if hit { + out.push(h); + } + } + out +} + +/// Solid (mesh) handles caught by a lasso polygon. Window mode needs every +/// projected vertex inside the lasso; crossing mode needs any vertex inside, +/// or the lasso to sit inside the solid. +pub fn mesh_poly_hit<'a>( + poly: &[Point], + crossing: bool, + meshes: impl Iterator, + view_proj: Mat4, + bounds: Rectangle, +) -> Vec { + if poly.len() < 3 { + return Vec::new(); + } + let mut out = Vec::new(); + for (h, mesh) in meshes { + let proj = project_mesh_verts(mesh, view_proj, bounds); + if proj.is_empty() { + continue; + } + let hit = if crossing { + proj.iter().any(|p| point_in_polygon(*p, poly)) + || mesh_covers_any(&proj, &mesh.indices, poly) + } else { + proj.iter().all(|p| point_in_polygon(*p, poly)) + }; + if hit { + out.push(h); + } + } + out +} + // ── Box / window selection ──────────────────────────────────────────────── /// Return the names of wires selected by a completed rectangular selection box.