From fcf1cedf52fa4649e458658d36b2cdef1ea4e228 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 13 Oct 2026 22:14:56 +0300 Subject: [PATCH] feat(select): Ctrl+click picks one segment of a polyline Ctrl+click on a polyline selects the segment under the cursor (again to drop it); several segments can be picked. Only the picked segments are highlighted, and MOVE, ROTATE, SCALE and MIRROR then change just their vertices, so the neighbouring segments stretch to follow. Any other selected objects are transformed whole as before. Co-Authored-By: Claude Opus 5.5 --- src/app/command_driver/transform.rs | 3 + src/app/update/viewport.rs | 33 ++++++ src/scene/mod.rs | 5 + src/scene/selection.rs | 1 + src/scene/sub_select.rs | 159 ++++++++++++++++++++++++++++ src/scene/view/render.rs | 20 +++- 6 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 src/scene/sub_select.rs diff --git a/src/app/command_driver/transform.rs b/src/app/command_driver/transform.rs index 2a158a622..421cdc274 100644 --- a/src/app/command_driver/transform.rs +++ b/src/app/command_driver/transform.rs @@ -24,6 +24,9 @@ impl OpenCADStudio { // component so each member keeps its own dimensions. Other // transforms retain their selected-object behavior. let pending = self.begin_undo(i, label, handles.len(), true); + let handles = self.tabs[i] + .scene + .transform_picked_segments(&handles, &transform); self.tabs[i].scene.transform_entities(&handles, &transform); self.apply_inferred_constraints(i, &handles); self.tabs[i].dirty = true; diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 6b00f7b67..158f4699f 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -4014,6 +4014,39 @@ impl OpenCADStudio { } } + // Ctrl+click on a polyline picks the segment under the cursor; the + // edit commands then move just that part. + if self.ctrl_down && self.tabs[i].active_cmd.is_none() { + let (view_rot, eye) = { + let cam = self.tabs[i].scene.camera.borrow(); + (cam.view_proj_rte(bounds), cam.eye()) + }; + let wires = self.tabs[i].scene.hit_test_wires(); + let hit = scene::pick::hit_test::click_hit( + p, + &*wires, + view_rot, + eye, + bounds, + self.tabs[i].scene.document.header.lineweight_display, + crate::ui::overlay::pick_box_aperture_px(self.pick_box), + &self.tabs[i].scene.draw_depth_map(), + ) + .and_then(Scene::handle_from_wire_name) + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); + drop(wires); + let point = self.tabs[i].last_cursor_world; + if hit.is_some_and(|handle| self.tabs[i].scene.toggle_sub_segment(handle, point)) { + self.tabs[i] + .scene + .selection + .borrow_mut() + .clear_left_selection_gesture(); + self.refresh_properties(); + return Task::none(); + } + } + // 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 diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 3cf1c1779..b49d74dc7 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -9,6 +9,7 @@ pub mod annotative; pub mod assoc_array; pub mod compare; pub mod section_cull; +pub mod sub_select; pub mod block_service; pub mod cache; pub mod convert; @@ -2124,6 +2125,9 @@ pub struct Scene { lighting_cache: RefCell>>, /// Currently selected entity handles. pub selected: HashSet, + /// Polylines only part of which is selected (Ctrl+click): the picked + /// segment indices. + pub sub_segments: HashMap>, selected_order: Vec, /// The parametric-constraint glyph currently selected in the viewport. /// Entity selection changes clear it so the two selections stay exclusive. @@ -2764,6 +2768,7 @@ impl Scene { object_data_cache: crate::entities::object_data::ObjectDataCache::default(), lighting_cache: RefCell::new(HashMap::default()), selected: HashSet::default(), + sub_segments: HashMap::default(), selected_order: Vec::new(), selected_constraint: None, object_isolation: ObjectIsolationState::default(), diff --git a/src/scene/selection.rs b/src/scene/selection.rs index 11b2a67d6..f4671b6dc 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -165,6 +165,7 @@ impl Scene { pub fn deselect_all(&mut self) { self.selected_constraint = None; + self.sub_segments.clear(); if self.selected.is_empty() { return; } diff --git a/src/scene/sub_select.rs b/src/scene/sub_select.rs new file mode 100644 index 000000000..adf9d4364 --- /dev/null +++ b/src/scene/sub_select.rs @@ -0,0 +1,159 @@ +//! Picking part of an object: Ctrl+click on a polyline selects the segment +//! under the cursor. The polyline joins the selection, only its picked +//! segments are highlighted, and MOVE / ROTATE / SCALE / MIRROR then change +//! just the vertices of those segments, stretching the neighbours. + +use std::collections::BTreeSet; + +use codec::entities::LwPolyline; +use codec::{EntityType, Handle}; +use glam::DVec3; + +use super::Scene; +use crate::command::EntityTransform; + +/// Segment `index` of `polyline` on its own: its two vertices, with the +/// bulge of the first. +fn segment(polyline: &LwPolyline, index: usize) -> Option { + let count = polyline.vertices.len(); + let next = if index + 1 < count { + index + 1 + } else if polyline.is_closed && count > 2 { + 0 + } else { + return None; + }; + let mut part = polyline.clone(); + part.vertices = vec![ + polyline.vertices[index].clone(), + polyline.vertices[next].clone(), + ]; + part.vertices[1].bulge = 0.0; + part.is_closed = false; + Some(part) +} + +fn segment_points(polyline: &LwPolyline, index: usize) -> Vec<[f64; 3]> { + segment(polyline, index) + .and_then(|part| crate::entities::curve::lwpolyline_curve(&part)) + .map(|curve| crate::entities::curve::curve_points(&curve)) + .unwrap_or_default() +} + +impl Scene { + /// Ctrl+click: toggle the segment of polyline `handle` nearest `point` + /// and keep the polyline selected while any of its segments is. `false` + /// when `handle` is not a polyline. + pub fn toggle_sub_segment(&mut self, handle: Handle, point: DVec3) -> bool { + let Some(EntityType::LwPolyline(polyline)) = self.document.get_entity(handle) else { + return false; + }; + let nearest = (0..polyline.vertices.len()) + .filter_map(|index| { + let distance = segment_points(polyline, index) + .windows(2) + .map(|pair| { + let a = DVec3::from_array(pair[0]).truncate(); + let b = DVec3::from_array(pair[1]).truncate(); + let p = point.truncate(); + let t = ((p - a).dot(b - a) / (b - a).length_squared().max(1e-300)) + .clamp(0.0, 1.0); + p.distance(a + (b - a) * t) + }) + .fold(f64::INFINITY, f64::min); + distance.is_finite().then_some((index, distance)) + }) + .min_by(|a, b| a.1.total_cmp(&b.1)); + let Some((index, _)) = nearest else { + return false; + }; + if !self.selected.contains(&handle) { + self.sub_segments.remove(&handle); + } + let segments = self.sub_segments.entry(handle).or_default(); + if !segments.remove(&index) { + segments.insert(index); + } + if segments.is_empty() { + self.sub_segments.remove(&handle); + self.deselect_entity(handle); + } else { + self.select_entity(handle, false); + } + self.bump_selection_set(); + true + } + + /// The picked segments of a selected polyline, if only part of it is. + pub(crate) fn picked_segments(&self, handle: Handle) -> Option<&BTreeSet> { + self.selected + .contains(&handle) + .then(|| self.sub_segments.get(&handle)) + .flatten() + } + + /// World points of the picked segments of `handle`, NaN-separated, for + /// the selection highlight. + pub(crate) fn picked_segment_points(&self, handle: Handle) -> Option> { + let segments = self.picked_segments(handle)?; + let Some(EntityType::LwPolyline(polyline)) = self.document.get_entity(handle) else { + return None; + }; + let mut points = Vec::new(); + for &index in segments { + points.extend(segment_points(polyline, index)); + points.push([f64::NAN; 3]); + } + Some(points) + } + + /// Apply `transform` to just the vertices of the picked segments of each + /// partly selected polyline in `handles`. Returns the handles left to + /// transform whole. + pub fn transform_picked_segments( + &mut self, + handles: &[Handle], + transform: &EntityTransform, + ) -> Vec { + let mut whole = Vec::with_capacity(handles.len()); + for &handle in handles { + let (Some(segments), Some(EntityType::LwPolyline(polyline))) = ( + self.picked_segments(handle), + self.document.get_entity(handle), + ) else { + whole.push(handle); + continue; + }; + let count = polyline.vertices.len(); + let vertices: BTreeSet = segments + .iter() + .flat_map(|&index| [index, (index + 1) % count]) + .collect(); + let Some(plane) = crate::entities::curve::lwpolyline_curve(polyline) + .map(|curve| curve.plane) + else { + whole.push(handle); + continue; + }; + let mut moved = polyline.clone(); + for index in vertices { + let vertex = &mut moved.vertices[index]; + let world = plane.point_at([vertex.location.x, vertex.location.y]); + let mut marker = EntityType::Point(codec::entities::Point::at( + codec::types::Vector3::new(world[0], world[1], world[2]), + )); + super::view::dispatch::apply_transform(&mut marker, transform); + let EntityType::Point(marker) = marker else { + continue; + }; + let location = marker.location; + if let Some([x, y]) = plane.project([location.x, location.y, location.z]) { + vertex.location.x = x; + vertex.location.y = y; + } + } + self.update_entity(EntityType::LwPolyline(moved)); + } + whole + } +} diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 9cb39f5c5..2113eab94 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -4056,6 +4056,18 @@ impl Scene { } else { WireModel::HOVER }; + if let Some(points) = selected + .then(|| self.picked_segment_points(handle)) + .flatten() + { + wires.push(WireModel::solid_f64( + handle.value().to_string(), + points, + tint, + true, + )); + continue; + } for set in interaction_meshes .iter() .filter(|set| set.entity_handle() == Some(handle)) @@ -4918,7 +4930,13 @@ impl Scene { wire_content_id, wire_patch, selected_handles: Arc::new(if show_interaction { - self.selected.iter().copied().collect() + // A partly picked polyline lights only its picked segments, + // drawn with the highlight wires. + self.selected + .iter() + .copied() + .filter(|handle| self.picked_segments(*handle).is_none()) + .collect() } else { rustc_hash::FxHashSet::default() }),