diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index d8a726a83..b98581034 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -1357,6 +1357,7 @@ draw = .solid-specify-second-point = SOLID Specify second point: .solid-specify-third-point = SOLID Specify third point: .spline-specify-first-control-point = SPLINE Specify first control point: + .spline-specify-first-point = SPLINE Specify first point: .spline-specify-next-point-n-pts = SPLINE Specify next point [__ocs_arg_n__ pts]: .sketch = Sketch .start-center-angle = Start, Center, Angle diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 2608db7c2..50d439a44 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -1339,6 +1339,7 @@ draw = .solid-specify-second-point = SOLID İkinci noktayı belirtin: .solid-specify-third-point = SOLID Üçüncü noktayı belirtin: .spline-specify-first-control-point = SPLINE İlk kontrol noktasını belirtin: + .spline-specify-first-point = SPLINE İlk noktayı belirtin: .spline-specify-next-point-n-pts = SPLINE Sonraki noktayı belirtin [__ocs_arg_n__ nokta]: .sketch = Serbest Çizim .start-center-angle = Başlangıç, Merkez, Açı diff --git a/src/app/mod.rs b/src/app/mod.rs index 604d8e656..3e52ef38a 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -82,6 +82,10 @@ pub struct GripPopup { pub anchor: iced::Point, pub items: Vec, pub selected: usize, + /// Click-opened selectors stay visible while the cursor travels from the + /// arrow to the menu. Hover-opened multifunction grips still dismiss by + /// distance as before. + pub pinned: bool, } /// Pending follow-up value for grip-menu actions that need a number diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index bd32c0277..089ddaac9 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -653,7 +653,9 @@ impl OpenCADStudio { screen: p, started: iced::time::Instant::now(), }); - self.grip_popup = None; + if !self.grip_popup.as_ref().is_some_and(|popup| popup.pinned) { + self.grip_popup = None; + } } else if let Some(h) = self.grip_hover.as_mut() { h.screen = p; } @@ -672,12 +674,17 @@ impl OpenCADStudio { use crate::entities::traits::EntityTypeOps; let items = e.grip_menu(grip_id); if !items.is_empty() { + let selected = items + .iter() + .position(|item| item.label.starts_with('✓')) + .unwrap_or(0); self.grip_popup = Some(crate::app::GripPopup { handle, grip_id, anchor: p, items, - selected: 0, + selected, + pinned: false, }); } } @@ -685,7 +692,7 @@ impl OpenCADStudio { } None => { self.grip_hover = None; - if let Some(popup) = &self.grip_popup { + if let Some(popup) = self.grip_popup.as_ref().filter(|popup| !popup.pinned) { let dx = p.x - popup.anchor.x; let dy = p.y - popup.anchor.y; if (dx * dx + dy * dy).sqrt() > POPUP_DISMISS_PX { @@ -2736,6 +2743,32 @@ impl OpenCADStudio { let Some(&handle) = self.tabs[i].selected_grip_handles.get(grip_index) else { return Task::none(); }; + let grip_shape = self.tabs[i].selected_grips[grip_index].shape; + if grip_shape == crate::scene::model::object::GripShape::Dropdown { + use crate::entities::traits::EntityTypeOps; + let items = self.tabs[i] + .scene + .document + .get_entity(handle) + .map(|entity| entity.grip_menu(grip_id)) + .unwrap_or_default(); + if !items.is_empty() { + let selected = items + .iter() + .position(|item| item.label.starts_with('✓')) + .unwrap_or(0); + self.grip_popup = Some(crate::app::GripPopup { + handle, + grip_id, + anchor: p_full, + items, + selected, + pinned: true, + }); + } + self.grip_hover = None; + return Task::none(); + } // The visibility (lookup) grip opens a state // dropdown instead of starting a stretch drag. if grip_id == crate::app::visibility::VIS_GRIP_ID { diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 078577713..6f5a4df2a 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -495,6 +495,23 @@ impl OpenCADStudio { } else { vec![] }; + let control_polygon = tab.selected_handle.and_then(|handle| { + let spline = match tab.scene.document.get_entity(handle) { + Some(acadrust::EntityType::Spline(spline)) if spline.cv_frame_visible => spline, + _ => return None, + }; + let points: Vec<_> = grips + .iter() + .filter(|grip| { + grip.shape == crate::scene::model::object::GripShape::Circle + }) + .map(|grip| grip.pos) + .collect(); + (points.len() >= 2).then_some(( + points, + spline.flags.closed || spline.flags.periodic, + )) + }); let grip_clip = if grips.is_empty() { None } else { @@ -688,6 +705,7 @@ impl OpenCADStudio { snap_ext_base, snap_ext_base2, grips, + control_polygon, grip_clip, ucs_icons, ost_points, diff --git a/src/entities/common.rs b/src/entities/common.rs index 09a416c46..e46b8595b 100644 --- a/src/entities/common.rs +++ b/src/entities/common.rs @@ -453,6 +453,31 @@ pub fn circle_grip(id: usize, world: glam::DVec3) -> GripDef { } } +/// Round vertex grip. Unlike [`circle_grip`], dragging this handle edits one +/// point rather than translating the whole entity. +pub fn round_grip(id: usize, world: glam::DVec3) -> GripDef { + GripDef { + id, + world, + is_midpoint: false, + shape: GripShape::Circle, + dir: None, + axis: None, + } +} + +/// Screen-offset down-arrow that opens a choice menu for an entity. +pub fn dropdown_grip(id: usize, world: glam::DVec3) -> GripDef { + GripDef { + id, + world, + is_midpoint: false, + shape: GripShape::Dropdown, + dir: None, + axis: None, + } +} + /// Mid-segment stretch grip oriented along `dir` (the segment's in-plane /// world-XY direction). Drawn as a small rectangle elongated along the /// segment so the affordance reads as "stretch perpendicular". diff --git a/src/entities/spline.rs b/src/entities/spline.rs index 00d9b91f6..da20e90d0 100644 --- a/src/entities/spline.rs +++ b/src/entities/spline.rs @@ -3,7 +3,9 @@ use crate::t; use cadkernel::space::NurbsCurve3; use crate::command::EntityTransform; -use crate::entities::common::{edit_prop as edit, parse_f64, ro_prop as ro, square_grip}; +use crate::entities::common::{ + dropdown_grip, edit_prop as edit, parse_f64, ro_prop as ro, round_grip, square_grip, +}; use crate::entities::traits::RenderConvertible; use crate::scene::convert::acad_to_render::{RenderEntity, RenderObject}; use crate::scene::model::object::{GripApply, GripDef, PropSection}; @@ -390,27 +392,60 @@ fn thomas_solve(a: &[f64], b: &[f64], c: &[f64], d: &mut [f64]) { } } +pub(crate) const SPLINE_MODE_GRIP_ID: usize = usize::MAX - 1; + +fn control_vertices(spline: &Spline) -> Vec { + if !spline.control_points.is_empty() { + return spline.control_points.clone(); + } + let elevation = spline.fit_points.first().map(|p| p.z).unwrap_or(0.0); + crate::modules::draw::modify::spline_ops::spline_control_curve(spline) + .map(|curve| { + curve + .control_points() + .iter() + .map(|p| acadrust::types::Vector3::new(p[0], p[1], elevation)) + .collect() + }) + .unwrap_or_default() +} + fn grips(spline: &Spline) -> Vec { - // A fit-point spline carries no control points, so its editable points — and - // therefore its grips — are the fit points it interpolates. A CV spline - // grips its control vertices. - let source = if spline.control_points.is_empty() { - &spline.fit_points + let (source, show_control_vertices) = if spline.cv_frame_visible { + (control_vertices(spline), true) + } else if !spline.fit_points.is_empty() { + (spline.fit_points.clone(), false) } else { - &spline.control_points + (spline.control_points.clone(), true) }; - source + let mut grips: Vec<_> = source .iter() .enumerate() - .map(|(i, p)| square_grip(i, glam::DVec3::new(p.x, p.y, p.z))) - .collect() + .map(|(i, p)| { + let world = glam::DVec3::new(p.x, p.y, p.z); + if show_control_vertices { + round_grip(i, world) + } else { + square_grip(i, world) + } + }) + .collect(); + if !spline.fit_points.is_empty() { + if let Some(first) = source.first() { + grips.push(dropdown_grip( + SPLINE_MODE_GRIP_ID, + glam::DVec3::new(first.x, first.y, first.z), + )); + } + } + grips } fn properties(spline: &Spline) -> Vec { - let show = if spline.fit_points.is_empty() { + let show = if spline.cv_frame_visible || spline.fit_points.is_empty() { "Control Vertices" } else { - "Fit Points" + "Fit" }; let cp0 = spline.control_points.first(); let fp0 = spline.fit_points.first(); @@ -566,13 +601,35 @@ fn apply_geom_prop(spline: &mut Spline, field: &str, value: &str) { } fn apply_grip(spline: &mut Spline, grip_id: usize, apply: GripApply) { - // Match the grip source: dragging a fit-point spline's grip moves the fit - // point (the curve re-fits through it on the next tessellation, keeping the - // stored end tangents); a CV spline moves the control vertex. - let target = if spline.control_points.is_empty() { - spline.fit_points.get_mut(grip_id) - } else { + if grip_id == SPLINE_MODE_GRIP_ID { + return; + } + // A fit spline can display its derived control polygon without changing + // storage. The first actual CV edit converts that exact derived curve to + // control-point storage so the dragged vertex remains authoritative when + // the drawing is saved. + if spline.cv_frame_visible && spline.control_points.is_empty() { + let elevation = spline.fit_points.first().map(|p| p.z).unwrap_or(0.0); + let Some(curve) = crate::modules::draw::modify::spline_ops::spline_control_curve(spline) + else { + return; + }; + spline.degree = curve.degree() as i32; + spline.knots = curve.knots().to_vec(); + spline.control_points = curve + .control_points() + .iter() + .map(|p| acadrust::types::Vector3::new(p[0], p[1], elevation)) + .collect(); + spline.weights = curve.is_rational().then(|| curve.weights().to_vec()).unwrap_or_default(); + spline.fit_points.clear(); + spline.begin_tangent = acadrust::types::Vector3::ZERO; + spline.end_tangent = acadrust::types::Vector3::ZERO; + } + let target = if spline.cv_frame_visible || spline.fit_points.is_empty() { spline.control_points.get_mut(grip_id) + } else { + spline.fit_points.get_mut(grip_id) }; if let Some(cp) = target { match apply { @@ -614,8 +671,33 @@ impl crate::entities::traits::Grippable for Spline { fn apply_grip(&mut self, grip_id: usize, apply: GripApply) { apply_grip(self, grip_id, apply); } - fn grip_menu(&self, _grip_id: usize) -> Vec { + fn grip_menu(&self, grip_id: usize) -> Vec { use crate::scene::model::object::{GripMenuAction, GripMenuItem}; + if grip_id == SPLINE_MODE_GRIP_ID { + return if self.cv_frame_visible { + vec![ + GripMenuItem { + label: "Fit", + action: GripMenuAction::ShowFit, + }, + GripMenuItem { + label: "✓ Control Vertices", + action: GripMenuAction::ShowControlVertices, + }, + ] + } else { + vec![ + GripMenuItem { + label: "✓ Fit", + action: GripMenuAction::ShowFit, + }, + GripMenuItem { + label: "Control Vertices", + action: GripMenuAction::ShowControlVertices, + }, + ] + }; + } vec![ GripMenuItem { label: "Stretch", @@ -641,6 +723,17 @@ impl crate::entities::traits::Grippable for Spline { action: crate::scene::model::object::GripMenuAction, ) { use crate::scene::model::object::GripMenuAction as A; + match action { + A::ShowFit if !self.fit_points.is_empty() => { + self.cv_frame_visible = false; + return; + } + A::ShowControlVertices if control_vertices(self).len() >= 2 => { + self.cv_frame_visible = true; + return; + } + _ => {} + } let n = self.control_points.len(); let min_cv = (self.degree as usize).saturating_add(1).max(2); match action { diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 832468a18..18322e33b 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -2192,6 +2192,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "SOLID Specify second point:" => Some(("draw", "solid-specify-second-point")), "SOLID Specify third point:" => Some(("draw", "solid-specify-third-point")), "SPLINE Specify first control point:" => Some(("draw", "spline-specify-first-control-point")), + "SPLINE Specify first point:" => Some(("draw", "spline-specify-first-point")), "SPLINE Specify next point [%{n} pts]:" => Some(("draw", "spline-specify-next-point-n-pts")), "SPLINEDIT Select spline:" => Some(("modify", "splinedit-select-spline")), "SPLINEDIT [CLOSE/OPEN/REVERSE/EXIT]:" => Some(("modify", "splinedit-close-open-reverse-exit")), diff --git a/src/modules/draw/draw/spline.rs b/src/modules/draw/draw/spline.rs index e8fe0c7b0..7f4ac9cb4 100644 --- a/src/modules/draw/draw/spline.rs +++ b/src/modules/draw/draw/spline.rs @@ -1,7 +1,7 @@ // Spline tool — ribbon definition + interactive command. // // Command: SPLINE (SPL) -// Click to add control points. Enter (≥2 pts) → commits EntityType::Spline. +// Click to add fit points. Enter (≥2 pts) → commits EntityType::Spline. use acadrust::types::Vector3; use acadrust::{EntityType, Spline}; @@ -11,7 +11,6 @@ use crate::command::{CadCommand, CmdResult}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; use glam::DVec3; -use cadkernel::space::NurbsCurve3; #[allow(dead_code)] pub fn tool() -> ToolDef { @@ -36,19 +35,14 @@ impl SplineCommand { if self.pts.len() < 2 { return None; } - let control_points = self + let fit_points = self .pts .iter() .map(|p| Vector3::new(p.x, p.y, p.z)) .collect(); - let n = self.pts.len(); - // Uniform open knot vector for degree-3 clamped B-spline - let degree = 3_i32.min((n - 1) as i32); - let knots = uniform_knots(n, degree as usize); let mut spline = Spline { - degree, - control_points, - knots, + degree: 3, + fit_points, ..Default::default() }; // Closed splines render with a segment bridging the last point back to @@ -58,48 +52,26 @@ impl SplineCommand { } } -fn uniform_knots(n: usize, d: usize) -> Vec { - let m = n + d + 1; - (0..m) - .map(|i| { - if i <= d { - 0.0 - } else if i >= m - d - 1 { - 1.0 - } else { - (i - d) as f64 / (n - d) as f64 - } - }) - .collect() -} - -/// Sample the degree-3 clamped B-spline through `pts` into a dense polyline, -/// built with the same control points + knot vector `build()` commits so the -/// preview traces the exact final curve — not the straight control polygon. -/// Fewer than 2 points has no curve; the points are returned as-is. -fn sample_curve(pts: &[DVec3]) -> Vec<[f32; 3]> { - let n = pts.len(); - if n < 2 { +/// Preview through the same fit-point representation that `build()` commits. +fn sample_curve(pts: &[DVec3], closed: bool) -> Vec<[f32; 3]> { + if pts.len() < 2 { return pts .iter() .map(|p| [p.x as f32, p.y as f32, p.z as f32]) .collect(); } - let degree = 3_usize.min(n - 1); - let ctrl: Vec<[f64; 3]> = pts.iter().map(|p| [p.x, p.y, p.z]).collect(); - let Some(curve) = NurbsCurve3::new(degree, ctrl, uniform_knots(n, degree), None) else { - return pts + let mut spline = Spline { + degree: 3, + fit_points: pts .iter() - .map(|p| [p.x as f32, p.y as f32, p.z as f32]) - .collect(); + .map(|p| Vector3::new(p.x, p.y, p.z)) + .collect(), + ..Default::default() }; - // Resolution scales with span count so long splines stay smooth. - let steps = 24 * (n - 1); - (0..=steps) - .map(|i| { - let p = curve.point_at(i as f64 / steps as f64); - [p[0] as f32, p[1] as f32, p[2] as f32] - }) + spline.flags.closed = closed; + crate::entities::spline::measurement_polyline(&spline) + .into_iter() + .map(|p| [p[0] as f32, p[1] as f32, p[2] as f32]) .collect() } @@ -110,7 +82,7 @@ impl CadCommand for SplineCommand { fn prompt(&self) -> String { if self.pts.is_empty() { - t!("SPLINE Specify first control point:").into_owned() + t!("SPLINE Specify first point:").into_owned() } else { let n = self.pts.len(); t!("SPLINE Specify next point [%{n} pts]:", n = n).into_owned() @@ -189,7 +161,7 @@ impl CadCommand for SplineCommand { ctrl.push(pt); Some(WireModel::solid( "rubber_band".into(), - sample_curve(&ctrl), + sample_curve(&ctrl, false), WireModel::CYAN, false, )) diff --git a/src/modules/draw/modify/spline_ops.rs b/src/modules/draw/modify/spline_ops.rs index b8adacd83..dbb65c8a3 100644 --- a/src/modules/draw/modify/spline_ops.rs +++ b/src/modules/draw/modify/spline_ops.rs @@ -47,6 +47,185 @@ pub fn spline_to_nurbs(spl: &Spline) -> Option { spline_to_nurbs_with(spl, |p| [p.x, p.y], |v| [v.x, v.y]) } +/// Return the compact control-polygon form of a spline for interactive grips. +/// +/// The kernel's fit-point interpolator intentionally stores one Bezier span +/// per fit-point interval. That is exact and convenient for evaluation, but +/// its repeated internal knots expose three controls per interval to an +/// editor. An open cubic interpolation has an equivalent simple-knot form +/// with only `fit point count + 2` controls. Solve that form here so the grip +/// polygon stays compact without changing the curve or its saved fit points. +pub fn spline_control_curve(spl: &Spline) -> Option { + let exact = spline_to_nurbs(spl)?; + if !spl.control_points.is_empty() + || spl.flags.closed + || spl.flags.periodic + || spl.fit_points.len() < 2 + { + return Some(exact); + } + + let fit: Vec<[f64; 2]> = spl.fit_points.iter().map(|p| [p.x, p.y]).collect(); + let parameters = fit_parameters(&fit, spl.knot_parameterization); + let control_count = fit.len() + 2; + let mut knots = vec![parameters[0]; 4]; + knots.extend(parameters.iter().take(fit.len() - 1).skip(1).copied()); + knots.extend([parameters[fit.len() - 1]; 4]); + + let mut matrix = Vec::with_capacity(control_count); + let mut right = Vec::with_capacity(control_count); + for (¶meter, &point) in parameters.iter().zip(&fit) { + matrix.push(bspline_basis(&knots, 3, parameter)); + right.push(point); + } + + // Scaling the derivative equations by the domain width keeps the solve + // well conditioned for drawings whose coordinates are very large/small. + let (start, end) = exact.domain(); + let width = (end - start).abs().max(1e-9); + matrix.push( + bspline_basis_derivative(&knots, 3, parameters[0]) + .into_iter() + .map(|value| value * width) + .collect(), + ); + let start_slope = exact.derivative_at_knot(start); + right.push([start_slope[0] * width, start_slope[1] * width]); + matrix.push( + bspline_basis_derivative(&knots, 3, parameters[fit.len() - 1]) + .into_iter() + .map(|value| value * width) + .collect(), + ); + let end_slope = exact.derivative_at_knot(end); + right.push([end_slope[0] * width, end_slope[1] * width]); + + let controls = solve_control_points(matrix, right)?; + NurbsCurve::new(3, controls, knots, None) +} + +fn fit_parameters(points: &[[f64; 2]], parameterization: i32) -> Vec { + let mut parameters = vec![0.0; points.len()]; + for i in 1..points.len() { + let dx = points[i][0] - points[i - 1][0]; + let dy = points[i][1] - points[i - 1][1]; + let chord = (dx * dx + dy * dy).sqrt().max(1e-9); + let step = match parameterization { + 2 => 1.0, + 1 => chord.sqrt(), + _ => chord, + }; + parameters[i] = parameters[i - 1] + step; + } + parameters +} + +fn bspline_basis(knots: &[f64], degree: usize, parameter: f64) -> Vec { + let count = knots.len() - degree - 1; + if (parameter - knots[knots.len() - 1]).abs() < 1e-12 { + let mut endpoint = vec![0.0; count]; + let last_span = knots + .windows(2) + .rposition(|span| span[0] < span[1]) + .unwrap_or(count - 1); + endpoint[last_span.min(count - 1)] = 1.0; + return endpoint; + } + + let mut values: Vec = knots + .windows(2) + .map(|span| { + if span[0] <= parameter && parameter < span[1] { + 1.0 + } else { + 0.0 + } + }) + .collect(); + for order in 1..=degree { + let next_count = knots.len() - order - 1; + let mut next = vec![0.0; next_count]; + for i in 0..next_count { + let left_span = knots[i + order] - knots[i]; + let right_span = knots[i + order + 1] - knots[i + 1]; + let left = if left_span.abs() < 1e-15 { + 0.0 + } else { + (parameter - knots[i]) / left_span * values[i] + }; + let right = if right_span.abs() < 1e-15 { + 0.0 + } else { + (knots[i + order + 1] - parameter) / right_span * values[i + 1] + }; + next[i] = left + right; + } + values = next; + } + values +} + +fn bspline_basis_derivative(knots: &[f64], degree: usize, parameter: f64) -> Vec { + let lower = bspline_basis(knots, degree - 1, parameter); + let count = knots.len() - degree - 1; + (0..count) + .map(|i| { + let left_span = knots[i + degree] - knots[i]; + let right_span = knots[i + degree + 1] - knots[i + 1]; + let left = if left_span.abs() < 1e-15 { + 0.0 + } else { + degree as f64 / left_span * lower[i] + }; + let right = if right_span.abs() < 1e-15 { + 0.0 + } else { + degree as f64 / right_span * lower[i + 1] + }; + left - right + }) + .collect() +} + +fn solve_control_points( + mut matrix: Vec>, + mut right: Vec<[f64; 2]>, +) -> Option> { + let count = right.len(); + for column in 0..count { + let pivot = (column..count).max_by(|&a, &b| { + matrix[a][column] + .abs() + .total_cmp(&matrix[b][column].abs()) + })?; + if matrix[pivot][column].abs() < 1e-14 { + return None; + } + matrix.swap(column, pivot); + right.swap(column, pivot); + + for row in column + 1..count { + let factor = matrix[row][column] / matrix[column][column]; + for col in column..count { + matrix[row][col] -= factor * matrix[column][col]; + } + right[row][0] -= factor * right[column][0]; + right[row][1] -= factor * right[column][1]; + } + } + + let mut result = vec![[0.0; 2]; count]; + for row in (0..count).rev() { + for axis in 0..2 { + let known: f64 = (row + 1..count) + .map(|column| matrix[row][column] * result[column][axis]) + .sum(); + result[row][axis] = (right[row][axis] - known) / matrix[row][row]; + } + } + Some(result) +} + /// [`spline_to_nurbs`] with the points expressed in `plane`'s coordinates. /// /// The two differ only for a spline whose extrusion normal is not +Z. Where diff --git a/src/scene/model/object.rs b/src/scene/model/object.rs index 39c04dc27..164454dc0 100644 --- a/src/scene/model/object.rs +++ b/src/scene/model/object.rs @@ -83,6 +83,9 @@ pub enum GripShape { Rectangle, Triangle, Circle, + /// Small down-arrow selector drawn at a fixed screen offset from its + /// world anchor. Clicking it opens the grip's choice menu. + Dropdown, } /// Describes one grip point for an entity. @@ -165,6 +168,8 @@ pub enum GripMenuAction { RemoveFitPoint, Refit, RefineVertices, + ShowFit, + ShowControlVertices, MoveWithText, StackText, UnstackText, diff --git a/src/scene/pick/grip.rs b/src/scene/pick/grip.rs index da19a4730..0605e1461 100644 --- a/src/scene/pick/grip.rs +++ b/src/scene/pick/grip.rs @@ -10,6 +10,19 @@ use crate::scene::model::object::{GripDef, GripShape}; pub const GRIP_THRESHOLD_PX: f32 = 8.0; /// Half-size of the rendered grip square / diamond in pixels. pub const GRIP_HALF_PX: f32 = 5.0; +/// Screen offset for dropdown selectors anchored to a vertex. Keeping this +/// asymmetric places the arrow below and just to the right of the endpoint, +/// clear of both the curve and its ordinary edit grip. +pub const GRIP_DROPDOWN_OFFSET_X_PX: f32 = 24.0; +pub const GRIP_DROPDOWN_OFFSET_Y_PX: f32 = 27.0; + +fn marker_screen_position(mut point: Point, shape: GripShape) -> Point { + if shape == GripShape::Dropdown { + point.x += GRIP_DROPDOWN_OFFSET_X_PX; + point.y += GRIP_DROPDOWN_OFFSET_Y_PX; + } + point +} // ── Active drag state ───────────────────────────────────────────────────── @@ -95,6 +108,7 @@ pub fn grips_to_screen( Some(p) => Point::new(bounds.x + p.x, bounds.y + p.y), None => Point::new(f32::NAN, f32::NAN), }; + let screen = marker_screen_position(screen, g.shape); (g.id, screen, g.is_midpoint, g.shape, g.dir) }) .collect() @@ -113,9 +127,12 @@ pub fn grips_to_screen_paper( grips .iter() .map(|g| { - let screen = Point::new( - (g.world.x as f32 - tx + half_w) / (2.0 * half_w) * bounds.width, - (ty + half_h - g.world.y as f32) / (2.0 * half_h) * bounds.height, + let screen = marker_screen_position( + Point::new( + (g.world.x as f32 - tx + half_w) / (2.0 * half_w) * bounds.width, + (ty + half_h - g.world.y as f32) / (2.0 * half_h) * bounds.height, + ), + g.shape, ); (g.id, screen, g.is_midpoint, g.shape, g.dir) }) @@ -136,9 +153,12 @@ pub fn find_hit_grip_paper( let mut best: Option<(usize, usize, bool, DVec3)> = None; for (index, g) in grips.iter().enumerate() { - let screen = Point::new( - (g.world.x as f32 - tx + half_w) / (2.0 * half_w) * bounds.width, - (ty + half_h - g.world.y as f32) / (2.0 * half_h) * bounds.height, + let screen = marker_screen_position( + Point::new( + (g.world.x as f32 - tx + half_w) / (2.0 * half_w) * bounds.width, + (ty + half_h - g.world.y as f32) / (2.0 * half_h) * bounds.height, + ), + g.shape, ); let dx = screen.x - cursor.x; let dy = screen.y - cursor.y; @@ -166,7 +186,7 @@ pub fn find_hit_grip( let Some(screen) = camera.project(g.world, bounds) else { continue; }; - let screen = Point::new(screen.x, screen.y); + let screen = marker_screen_position(Point::new(screen.x, screen.y), g.shape); let dx = screen.x - cursor.x; let dy = screen.y - cursor.y; let d = (dx * dx + dy * dy).sqrt(); @@ -211,6 +231,7 @@ pub fn grips_to_screen_rte( Some(p) => Point::new(bounds.x + p.x, bounds.y + p.y), None => Point::new(f32::NAN, f32::NAN), }; + let screen = marker_screen_position(screen, g.shape); (g.id, screen, g.is_midpoint, g.shape, g.dir) }) .collect() @@ -232,6 +253,7 @@ pub fn find_hit_grip_rte( let Some(screen) = project_rte(g.world, view_rot, eye, bounds) else { continue; }; + let screen = marker_screen_position(Point::new(screen.x, screen.y), g.shape); let dx = screen.x - cursor.x; let dy = screen.y - cursor.y; let d = (dx * dx + dy * dy).sqrt(); diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 5b1d41d3d..c4898b209 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -263,6 +263,7 @@ pub fn selection_overlay<'a>( snap_ext_base: Option, snap_ext_base2: Option, grips: Vec, + control_polygon: Option<(Vec, bool)>, grip_clip: Option, ucs_icons: Vec, ost_points: Vec, @@ -284,6 +285,7 @@ pub fn selection_overlay<'a>( snap_ext_base, snap_ext_base2, grips, + control_polygon, grip_clip, ucs_icons, ost_points, @@ -314,6 +316,8 @@ struct SelectionCanvas { /// both crossing extensions stay drawn when the crossing is caught. (#247, #259) snap_ext_base2: Option, grips: Vec, + /// Selected spline's screen-space control polygon and whether it closes. + control_polygon: Option<(Vec, bool)>, /// Active 3D pane / floating viewport rectangle. Grip markers are clipped /// here so orbiting cannot leak them into paper space or adjacent panes. grip_clip: Option, @@ -392,6 +396,12 @@ fn draw_grip_marker(frame: &mut canvas::Frame, grip: &GripMarker, theme: &Theme) b.close(); }), GripShape::Circle => canvas::Path::circle(Point::new(sp.x, sp.y), h), + GripShape::Dropdown => canvas::Path::new(|b| { + b.move_to(Point::new(sp.x - h, sp.y - h * 0.5)); + b.line_to(Point::new(sp.x + h, sp.y - h * 0.5)); + b.line_to(Point::new(sp.x, sp.y + h)); + b.close(); + }), }; if grip.is_hot { @@ -410,10 +420,12 @@ fn draw_grip_marker(frame: &mut canvas::Frame, grip: &GripMarker, theme: &Theme) } else { let palette = theme.palette(); let color = palette.primary.base.color; - frame.fill( - &path, - palette.background.base.color.scale_alpha(0.7), - ); + let fill = if grip.shape == GripShape::Dropdown { + color + } else { + palette.background.base.color.scale_alpha(0.7) + }; + frame.fill(&path, fill); frame.stroke( &path, canvas::Stroke { @@ -686,6 +698,31 @@ impl canvas::Program for SelectionCanvas { height: grip_y1 - grip_y0, }; frame.with_clip(grip_clip, |frame| { + if let Some((points, closed)) = &self.control_polygon { + if points.len() >= 2 { + let polygon = canvas::Path::new(|builder| { + builder.move_to(points[0]); + for point in points.iter().skip(1) { + builder.line_to(*point); + } + if *closed { + builder.line_to(points[0]); + } + }); + frame.stroke( + &polygon, + canvas::Stroke { + width: 1.0, + style: canvas::Style::Solid(Color::from_rgb(0.72, 0.32, 0.15)), + line_dash: canvas::LineDash { + segments: &[4.0, 4.0], + offset: 0, + }, + ..Default::default() + }, + ); + } + } for grip in &self.grips { draw_grip_marker(frame, grip, theme); }