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() }),