From 33640e050d579fdbb6ad6acb7291ad0a0d319f5a Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 7 Oct 2026 12:51:32 +0300 Subject: [PATCH] feat(solid history): Ctrl+click picks a composite's solid; show history draws them Ctrl+click on a composite selects it with the solid under the cursor as the one its palette and grips edit: the solid whose own surface passes through the picked point. With its history shown, a composite draws the edges of the solids its booleans joined, each where it sits. Co-Authored-By: Claude Opus 5.5 --- src/app/update/viewport.rs | 32 +++++++++++- src/scene/mod.rs | 32 ++++++++++++ src/scene/model/solid_history.rs | 89 ++++++++++++++++++++++++++++++++ src/scene/pick/hit_test.rs | 11 ++++ 4 files changed, 163 insertions(+), 1 deletion(-) diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index d38d7a4a5..395502e3b 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -656,7 +656,7 @@ impl OpenCADStudio { } } - fn grip_edit_for_hit( + pub(in crate::app) fn grip_edit_for_hit( &mut self, i: usize, handle: Handle, @@ -3738,6 +3738,36 @@ impl OpenCADStudio { } } + // Ctrl+click on a composite solid picks the solid of it under the + // cursor — the one the Properties palette and grips then edit. + if self.ctrl_down + && self.tabs[i].active_cmd.is_none() + && self.tabs[i].scene.current_layout == "Model" + { + let (view_rot, eye) = { + let cam = self.tabs[i].scene.camera.borrow(); + (cam.view_proj_rte(bounds), cam.eye()) + }; + let hit = self.tabs[i].scene.solid_click_hit_point(p, view_rot, eye, bounds); + if let Some((handle, _)) = hit.filter(|(handle, point)| { + crate::scene::model::solid_history::select_operand_at( + &self.tabs[i].scene.document, + *handle, + *point, + ) + }) { + self.tabs[i] + .scene + .selection + .borrow_mut() + .clear_left_selection_gesture(); + self.tabs[i].scene.deselect_all(); + self.tabs[i].scene.select_entity(handle, false); + self.refresh_properties(); + return Task::none(); + } + } + // A command step that takes a point or a pick commits on the press, // so the cursor moving before the button comes up cannot move it. // Selection steps and drag-fence picks still wait for the release. diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 8669fd033..4dc17ad3d 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -162,6 +162,11 @@ pub(crate) fn tessellate_entity( } wires.extend(construction); } + for points in model::solid_history::composite_history_edges(document, handle) { + let selected = selected.contains(&handle); + let color = [1.0, 0.25, 0.25, 1.0]; + wires.push(WireModel::solid_f64(handle.value().to_string(), points, color, selected)); + } } wires } @@ -10557,6 +10562,33 @@ impl Scene { ) } + /// The solid under the cursor and where the pick ray meets it. + pub fn solid_click_hit_point( + &self, + cursor: iced::Point, + view_rot: glam::Mat4, + eye: glam::DVec3, + bounds: iced::Rectangle, + ) -> Option<(Handle, glam::DVec3)> { + let meshes = self.interaction_meshes_arc(); + pick::hit_test::mesh_click_handle_point( + cursor, + meshes.iter().filter_map(|set| { + let handle = set.entity_handle()?; + let mesh = set.geometry_lods().first()?; + Some(( + handle, + mesh, + set.instance_transform, + mesh_interaction_aabb(set)?, + )) + }), + view_rot, + eye, + bounds, + ) + } + pub fn solid_planar_face_normal_at( &mut self, handle: Handle, diff --git a/src/scene/model/solid_history.rs b/src/scene/model/solid_history.rs index 488c7a6c0..ad407fdff 100644 --- a/src/scene/model/solid_history.rs +++ b/src/scene/model/solid_history.rs @@ -135,6 +135,95 @@ pub fn select_operand(document: &codec::CadDocument, handle: codec::Handle, labe } } +/// Pick the operand of a composite whose own surface passes through +/// `point` — where a click landed on the composite — for editing. False when +/// the solid has no boolean step or no operand surface is there. +/// +/// The point comes from the displayed mesh, so it sits off the true surface +/// by the tessellation's chord; the search widens until some operand's +/// surface is in reach, and the first in reach wins. +pub fn select_operand_at( + document: &codec::CadDocument, + handle: codec::Handle, + point: glam::DVec3, +) -> bool { + let operands = placed_operands(document, handle); + let size = operands + .iter() + .filter_map(|(_, body)| kernel::brep::body_bounds(body)) + .map(|bounds| { + (0..3) + .map(|axis| bounds.max[axis] - bounds.min[axis]) + .fold(0.0_f64, f64::max) + }) + .fold(0.0_f64, f64::max); + for reach in [1e-6, 1e-4, 1e-3, 1e-2] { + let found = operands.iter().find(|(_, body)| { + kernel::brep::contains_point(body, point.to_array(), reach * size) + == kernel::brep::Containment::OnBoundary + }); + if let Some((id, _)) = found { + EDIT_OPERANDS.with(|operands| operands.borrow_mut().insert(handle, *id)); + return true; + } + } + false +} + +/// The solids a composite's booleans joined, each rebuilt where it sits in +/// the composite, with its node id. Empty for a solid with no boolean step. +fn placed_operands( + document: &codec::CadDocument, + handle: codec::Handle, +) -> Vec<(i32, kernel::brep::Body)> { + let Some(tree) = document + .solid_history_tree(handle) + .filter(|tree| tree.has_boolean()) + else { + return Vec::new(); + }; + tree.leaves() + .into_iter() + .filter_map(|operation| { + let id = operation.base()?.node_id(); + let frame = ancestors_frame(&tree, id)?; + let mut placed = operation.clone(); + let base = placed.base_mut()?; + base.transform = (frame * matrix(base.transform)?).to_cols_array(); + Some((id, kernel::acis::rebuild_body(&placed).ok()?)) + }) + .collect() +} + +/// With its history shown, a composite draws the solids its booleans joined, +/// each where it sits: their edges, as world polylines. +pub fn composite_history_edges( + document: &codec::CadDocument, + handle: codec::Handle, +) -> Vec> { + let Some((_, object_show_history, show_history_mode)) = history_flags(document, handle) else { + return Vec::new(); + }; + if !displayed_history_state(object_show_history, show_history_mode).0 { + return Vec::new(); + } + placed_operands(document, handle) + .iter() + .flat_map(|(_, body)| { + kernel::brep::mesh::tessellate_wireframe( + body, + kernel::brep::mesh::TessellationTolerance::new( + kernel::tessellation::DEFAULT_ANGLE, + 1e-9, + ), + ) + .edges + }) + .map(|edge| edge.positions) + .filter(|positions| positions.len() >= 2) + .collect() +} + /// The composite's history and the node id of the operand being edited: the /// one last chosen, or the first. fn selected_operand( diff --git a/src/scene/pick/hit_test.rs b/src/scene/pick/hit_test.rs index 043c25320..0649563f6 100644 --- a/src/scene/pick/hit_test.rs +++ b/src/scene/pick/hit_test.rs @@ -904,6 +904,17 @@ pub(crate) fn mesh_click_point<'a>( mesh_click_result(cursor, meshes, view_rot, eye, bounds).map(|(_, _, point)| point) } +/// The nearest mesh under the cursor and where the pick ray meets it. +pub(crate) fn mesh_click_handle_point<'a>( + cursor: Point, + meshes: impl Iterator>, + view_rot: Mat4, + eye: glam::DVec3, + bounds: Rectangle, +) -> Option<(Handle, glam::DVec3)> { + mesh_click_result(cursor, meshes, view_rot, eye, bounds).map(|(_, handle, point)| (handle, point)) +} + fn mesh_click_result<'a>( cursor: Point, meshes: impl Iterator>,