From 0d37d3ff787cf6729604cc0a6a007b4e8c0cb842 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:02:32 +0300 Subject: [PATCH 1/3] Improve spline fit and control vertex grips --- locales/en-US/opencadstudio.ftl | 1 + locales/tr-TR/opencadstudio.ftl | 1 + src/app/mod.rs | 4 + src/app/update/viewport.rs | 39 +++++- src/app/view/mod.rs | 18 +++ src/entities/common.rs | 25 ++++ src/entities/spline.rs | 131 ++++++++++++++++--- src/locale_catalog.rs | 1 + src/modules/draw/draw/spline.rs | 66 +++------- src/modules/draw/modify/spline_ops.rs | 179 ++++++++++++++++++++++++++ src/scene/model/object.rs | 5 + src/scene/pick/grip.rs | 36 +++++- src/ui/overlay.rs | 45 ++++++- 13 files changed, 471 insertions(+), 80 deletions(-) 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); } From 5595837a694245ca30f94f6b38e73f40a8933ade Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:00:02 +0300 Subject: [PATCH 2/3] Align spline properties and editing --- locales/en-US/opencadstudio.ftl | 25 ++ locales/tr-TR/opencadstudio.ftl | 25 ++ src/app/properties.rs | 5 + src/app/update/command.rs | 33 ++ src/entities/spline.rs | 491 +++++++++++++++++++------- src/locale_catalog.rs | 25 ++ src/modules/draw/draw/spline.rs | 1 + src/modules/draw/modify/spline_ops.rs | 78 +++- 8 files changed, 552 insertions(+), 131 deletions(-) diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index b98581034..313cba46a 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -2353,6 +2353,7 @@ properties = .chord-x = Chord X .chord-y = Chord Y .chord-z = Chord Z + .chord = Chord .circumference = Circumference .class = Class .class-id = Class ID @@ -2379,6 +2380,10 @@ properties = .control-point-x = Control Point X .control-point-y = Control Point Y .control-point-z = Control Point Z + .control-point-x-sentence-case = Control point X + .control-point-y-sentence-case = Control point Y + .control-point-z-sentence-case = Control point Z + .control-vertices = Control Vertices .coordination-model = Coordination Model .creased-edges = Creased Edges .croppings = Croppings @@ -2386,6 +2391,9 @@ properties = .current-color-scheme = Current Color Scheme .current-vertex-title-case = Current Vertex .current-vertex-sentence-case = Current vertex + .current-control-point = Current Control point + .current-fit-point = Current Fit point + .cv-frame = CV frame .cutting-line = Cutting Line .dwg-payload = DWG Payload .dwg-version = DWG Version @@ -2445,6 +2453,9 @@ properties = .end-tangent-x = End Tangent X .end-tangent-y = End Tangent Y .end-tangent-z = End Tangent Z + .end-tangent-vector-x = End tangent vector X + .end-tangent-vector-y = End tangent vector Y + .end-tangent-vector-z = End tangent vector Z .end-x = End X .end-y = End Y .end-z = End Z @@ -2482,6 +2493,10 @@ properties = .fit-point-y = Fit Point Y .fit-point-z = Fit Point Z .fit-tolerance = Fit Tolerance + .fit-point-x-sentence-case = Fit point X + .fit-point-y-sentence-case = Fit point Y + .fit-point-z-sentence-case = Fit point Z + .fit-tolerance-sentence-case = Fit tolerance .fit-smooth-title-case = Fit/Smooth .fit-smooth-sentence-case = Fit/smooth .fixed-elevation-range = Fixed Elevation Range @@ -2527,6 +2542,7 @@ properties = .jog-z = Jog Z .justify = Justify .knot-parameterization = Knot Parameterization + .knot-parameterization-sentence-case = Knot parameterization .lamp-preset = Lamp Preset .landing-gap = Landing Gap .layout-print-configuration = Layout Print Configuration @@ -2619,6 +2635,8 @@ properties = .number-of-grips = Number of Grips .number-of-vertices = Number of Vertices .number-of-turns = Number of turns + .number-of-control-points = Number of control points + .number-of-fit-points = Number of fit points .ole-frame = OLE Frame .object-references = Object References .obliquing = Obliquing @@ -2738,6 +2756,13 @@ properties = .start-tangent-x = Start Tangent X .start-tangent-y = Start Tangent Y .start-tangent-z = Start Tangent Z + .start-tangent-vector-x = Start tangent vector X + .start-tangent-vector-y = Start tangent vector Y + .start-tangent-vector-z = Start tangent vector Z + .hide = Hide + .periodic = Periodic + .square-root = Square Root + .uniform = Uniform .start-x = Start X .start-y = Start Y .start-z = Start Z diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 50d439a44..b8d18e9c2 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -2293,6 +2293,7 @@ properties = .chord-x = Kiriş X .chord-y = Kiriş Y .chord-z = Kiriş Z + .chord = Kiriş .circumference = Çevre .class = Sınıf .class-id = Sınıf Kimliği @@ -2319,6 +2320,10 @@ properties = .control-point-x = Kontrol Noktası X .control-point-y = Kontrol Noktası Y .control-point-z = Kontrol Noktası Z + .control-point-x-sentence-case = Kontrol noktası X + .control-point-y-sentence-case = Kontrol noktası Y + .control-point-z-sentence-case = Kontrol noktası Z + .control-vertices = Kontrol Köşeleri .coordination-model = Koordinasyon Modeli .creased-edges = Keskin Kenarlar .croppings = Kırpmalar @@ -2326,6 +2331,9 @@ properties = .current-color-scheme = Geçerli Renk Düzeni .current-vertex-title-case = Geçerli Köşe .current-vertex-sentence-case = Geçerli köşe + .current-control-point = Geçerli kontrol noktası + .current-fit-point = Geçerli uydurma noktası + .cv-frame = Kontrol çerçevesi .cutting-line = Kesme Çizgisi .dwg-payload = DWG Verisi .dwg-version = DWG Sürümü @@ -2385,6 +2393,9 @@ properties = .end-tangent-x = Bitiş Teğeti X .end-tangent-y = Bitiş Teğeti Y .end-tangent-z = Bitiş Teğeti Z + .end-tangent-vector-x = Bitiş teğet vektörü X + .end-tangent-vector-y = Bitiş teğet vektörü Y + .end-tangent-vector-z = Bitiş teğet vektörü Z .end-x = Bitiş X .end-y = Bitiş Y .end-z = Bitiş Z @@ -2422,6 +2433,10 @@ properties = .fit-point-y = Uydurma Noktası Y .fit-point-z = Uydurma Noktası Z .fit-tolerance = Uydurma Toleransı + .fit-point-x-sentence-case = Uydurma noktası X + .fit-point-y-sentence-case = Uydurma noktası Y + .fit-point-z-sentence-case = Uydurma noktası Z + .fit-tolerance-sentence-case = Uydurma toleransı .fit-smooth-title-case = Uydur/Yumuşat .fit-smooth-sentence-case = Uydur/yumuşat .fixed-elevation-range = Sabit Kot Aralığı @@ -2467,6 +2482,7 @@ properties = .jog-z = Kırılma Z .justify = Hizala .knot-parameterization = Düğüm Parametrelemesi + .knot-parameterization-sentence-case = Düğüm parametrelemesi .lamp-preset = Lamba Ön Ayarı .landing-gap = Yatay Bağlantı Boşluğu .layout-print-configuration = Yerleşim Yazdırma Yapılandırması @@ -2559,6 +2575,8 @@ properties = .number-of-grips = Tutamaç Sayısı .number-of-vertices = Köşe Sayısı .number-of-turns = Sarım sayısı + .number-of-control-points = Kontrol noktası sayısı + .number-of-fit-points = Uydurma noktası sayısı .ole-frame = OLE Çerçevesi .object-references = Nesne Referansları .obliquing = Eğiklik @@ -2678,6 +2696,13 @@ properties = .start-tangent-x = Başlangıç Teğeti X .start-tangent-y = Başlangıç Teğeti Y .start-tangent-z = Başlangıç Teğeti Z + .start-tangent-vector-x = Başlangıç teğet vektörü X + .start-tangent-vector-y = Başlangıç teğet vektörü Y + .start-tangent-vector-z = Başlangıç teğet vektörü Z + .hide = Gizle + .periodic = Periyodik + .square-root = Karekök + .uniform = Eşit Aralıklı .start-x = Başlangıç X .start-y = Başlangıç Y .start-z = Başlangıç Z diff --git a/src/app/properties.rs b/src/app/properties.rs index 21a5fbaea..450585bc5 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -121,6 +121,11 @@ impl OpenCADStudio { acadrust::EntityType::LwPolyline(polyline) => Some(polyline.vertices.len()), acadrust::EntityType::Polyline2D(polyline) => Some(polyline.vertices.len()), acadrust::EntityType::Leader(leader) => Some(leader.vertices.len()), + acadrust::EntityType::Spline(spline) => Some(if spline.fit_points.is_empty() { + spline.control_points.len() + } else { + spline.fit_points.len() + }), _ => None, }); vertex_count.map_or(prop_vertex, |count| prop_vertex.min(count.saturating_sub(1))) diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 026b72031..6f717cbb5 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -2099,6 +2099,10 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } self.invalidate_property_targets(i, &handles); self.tabs[i].dirty = true; + if field == "spline_method" { + self.tabs[i].properties.prop_vertex = 0; + self.tabs[i].properties.prop_vertex_indicator_active = false; + } self.refresh_properties(); } Task::none() @@ -2121,6 +2125,35 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } else { crate::app::expr_eval::eval_to_string(&raw_val) }; + if matches!(field, "current_fit_point" | "current_control_point") { + let count = handles + .iter() + .filter_map(|handle| { + let entity = self.tabs[i].scene.document.get_entity(*handle)?; + match (field, entity) { + ( + "current_fit_point", + acadrust::EntityType::Spline(spline), + ) => Some(spline.fit_points.len()), + ( + "current_control_point", + acadrust::EntityType::Spline(spline), + ) => Some(spline.control_points.len()), + _ => None, + } + }) + .min() + .unwrap_or(0); + if let Ok(requested) = val.trim().parse::() { + if count > 0 { + let next = requested.clamp(1, count) - 1; + self.tabs[i].properties.prop_vertex = next; + self.tabs[i].properties.prop_vertex_indicator_active = true; + } + } + self.refresh_properties(); + return Task::none(); + } self.push_undo_snapshot(i, "CHPROP"); if field == "block" { // Name row on a block reference: an existing name diff --git a/src/entities/spline.rs b/src/entities/spline.rs index da20e90d0..b5692e424 100644 --- a/src/entities/spline.rs +++ b/src/entities/spline.rs @@ -8,7 +8,7 @@ use crate::entities::common::{ }; use crate::entities::traits::RenderConvertible; use crate::scene::convert::acad_to_render::{RenderEntity, RenderObject}; -use crate::scene::model::object::{GripApply, GripDef, PropSection}; +use crate::scene::model::object::{GripApply, GripDef, PropSection, PropValue, Property}; fn to_render(spl: &Spline) -> RenderEntity { let n = spl.control_points.len(); @@ -410,6 +410,158 @@ fn control_vertices(spline: &Spline) -> Vec { .unwrap_or_default() } +fn choice_prop( + label: &str, + field: &'static str, + selected: &str, + options: &[&str], +) -> Property { + Property { + label: label.into(), + field, + value: PropValue::Choice { + selected: selected.to_string(), + options: options.iter().map(|option| option.to_string()).collect(), + }, + } +} + +fn index_prop(label: &str, field: &'static str, index: usize, count: usize) -> Property { + Property { + label: label.into(), + field, + value: PropValue::EditText(if count == 0 { + "0".to_string() + } else { + (index + 1).to_string() + }), + } +} + +fn convert_to_control_method(spline: &mut Spline) -> bool { + if !spline.control_points.is_empty() && spline.fit_points.is_empty() { + return true; + } + let elevation = spline.fit_points.first().map(|point| point.z).unwrap_or(0.0); + let Some(curve) = crate::modules::draw::modify::spline_ops::spline_control_curve(spline) else { + return false; + }; + spline.degree = curve.degree() as i32; + spline.knots = curve.knots().to_vec(); + spline.control_points = curve + .control_points() + .iter() + .map(|point| acadrust::types::Vector3::new(point[0], point[1], elevation)) + .collect(); + spline.weights = if curve.is_rational() { + curve.weights().to_vec() + } else { + Vec::new() + }; + spline.flags.rational = curve.is_rational(); + spline.fit_points.clear(); + spline.begin_tangent = acadrust::types::Vector3::ZERO; + spline.end_tangent = acadrust::types::Vector3::ZERO; + true +} + +fn convert_to_fit_method(spline: &mut Spline) -> bool { + if !spline.fit_points.is_empty() { + return true; + } + let degree = spline.degree.max(1) as usize; + let controls: Vec<_> = spline + .control_points + .iter() + .map(|point| [point.x, point.y, point.z]) + .collect(); + let weights = (spline.weights.len() == controls.len()).then(|| spline.weights.clone()); + let Some(curve) = NurbsCurve3::new(degree, controls, spline.knots.clone(), weights) else { + return false; + }; + let (start, end) = curve.domain(); + let mut parameters: Vec = curve.knots()[degree..=curve.control_points().len()] + .iter() + .copied() + .filter(|parameter| *parameter >= start && *parameter <= end) + .collect(); + parameters.dedup_by(|left, right| (*left - *right).abs() < 1e-12); + let sample_count = if degree == 3 { + curve.control_points().len().saturating_sub(2).max(2) + } else { + curve.control_points().len().max(4) + }; + for step in 0..sample_count { + let fraction = step as f64 / (sample_count - 1) as f64; + parameters.push(start + (end - start) * fraction); + } + parameters.sort_by(f64::total_cmp); + parameters.dedup_by(|left, right| (*left - *right).abs() < 1e-12); + + spline.fit_points = parameters + .iter() + .map(|parameter| { + let point = curve.point_at_knot(*parameter); + acadrust::types::Vector3::new(point[0], point[1], point[2]) + }) + .collect(); + let begin = curve.tangent_at_knot(start); + let end_tangent = curve.tangent_at_knot(end); + spline.begin_tangent = acadrust::types::Vector3::new(begin[0], begin[1], begin[2]); + spline.end_tangent = + acadrust::types::Vector3::new(end_tangent[0], end_tangent[1], end_tangent[2]); + spline.degree = 3; + spline.knots.clear(); + spline.control_points.clear(); + spline.weights.clear(); + spline.flags.rational = false; + spline.knot_parameterization = 0; + true +} + +fn is_planar(spline: &Spline) -> bool { + let points = if spline.fit_points.is_empty() { + &spline.control_points + } else { + &spline.fit_points + }; + if points.len() <= 3 { + return true; + } + let origin = points[0]; + let Some(axis) = points.iter().skip(1).find_map(|point| { + let vector = [point.x - origin.x, point.y - origin.y, point.z - origin.z]; + let length2 = vector.iter().map(|value| value * value).sum::(); + (length2 > 1e-18).then_some(vector) + }) else { + return true; + }; + let Some(normal) = points.iter().skip(1).find_map(|point| { + let vector = [point.x - origin.x, point.y - origin.y, point.z - origin.z]; + let cross = [ + axis[1] * vector[2] - axis[2] * vector[1], + axis[2] * vector[0] - axis[0] * vector[2], + axis[0] * vector[1] - axis[1] * vector[0], + ]; + let length2 = cross.iter().map(|value| value * value).sum::(); + (length2 > 1e-18).then_some(cross) + }) else { + return true; + }; + let normal_length = normal.iter().map(|value| value * value).sum::().sqrt(); + points.iter().all(|point| { + let offset = [point.x - origin.x, point.y - origin.y, point.z - origin.z]; + let distance = normal + .iter() + .zip(offset) + .map(|(component, value)| component * value) + .sum::() + .abs() + / normal_length; + distance <= 1e-8 + }) +} + fn grips(spline: &Spline) -> Vec { let (source, show_control_vertices) = if spline.cv_frame_visible { (control_vertices(spline), true) @@ -430,166 +582,267 @@ fn grips(spline: &Spline) -> Vec { } }) .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), - )); - } + 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.cv_frame_visible || spline.fit_points.is_empty() { - "Control Vertices" - } else { - "Fit" - }; - let cp0 = spline.control_points.first(); - let fp0 = spline.fit_points.first(); - let w0 = spline.weights.first().copied(); - - // Polyline-approximation length over the effective defining points. - let pts = if spline.fit_points.is_empty() { - &spline.control_points - } else { - &spline.fit_points - }; - let mut length = 0.0; - for w in pts.windows(2) { - let dx = w[1].x - w[0].x; - let dy = w[1].y - w[0].y; - let dz = w[1].z - w[0].z; - length += (dx * dx + dy * dy + dz * dz).sqrt(); - } - + let fit_method = !spline.fit_points.is_empty(); + let method = if fit_method { "Fit" } else { "Control Vertices" }; let closed = spline.flags.closed || spline.flags.periodic; let yes_no = |b: bool| if b { "Yes" } else { "No" }; - - // Knot parameterization method (R2013+ DWG); older splines report 0. let knot_param = match spline.knot_parameterization { 0 => "Chord", 1 => "Square Root", 2 => "Uniform", + 15 => "Custom", _ => "Custom", }; - - // Closed splines enclose an area; approximate it with the shoelace - // formula over the defining points projected to the XY plane, matching - // the polyline approximation already used for Length. - let area = if closed && pts.len() >= 3 { - let mut acc = 0.0; - for w in pts.windows(2) { - acc += w[0].x * w[1].y - w[1].x * w[0].y; - } - if let (Some(first), Some(last)) = (pts.first(), pts.last()) { - acc += last.x * first.y - first.x * last.y; - } - format!("{:.4}", acc.abs() * 0.5) + let current = crate::scene::view::dispatch::prop_current_vertex(); + let mut data_points = if fit_method { + let count = spline.fit_points.len(); + let index = current.min(count.saturating_sub(1)); + let point = spline.fit_points.get(index); + vec![ + ro( + t!("Number of fit points").as_ref(), + "fit_pt_count", + count.to_string(), + ), + index_prop( + t!("Current Fit point").as_ref(), + "current_fit_point", + index, + count, + ), + edit( + t!("Fit point X").as_ref(), + "fit_pt_x", + point.map(|value| value.x).unwrap_or(0.0), + ), + edit( + t!("Fit point Y").as_ref(), + "fit_pt_y", + point.map(|value| value.y).unwrap_or(0.0), + ), + edit( + t!("Fit point Z").as_ref(), + "fit_pt_z", + point.map(|value| value.z).unwrap_or(0.0), + ), + choice_prop( + t!("Knot parameterization").as_ref(), + "knot_param", + knot_param, + &["Chord", "Square Root", "Uniform", "Custom"], + ), + ] } else { - String::new() + let count = spline.control_points.len(); + let index = current.min(count.saturating_sub(1)); + let point = spline.control_points.get(index); + let weight = spline.weights.get(index).copied().unwrap_or(1.0); + vec![ + ro( + t!("Number of control points").as_ref(), + "ctrl_pt_count", + count.to_string(), + ), + index_prop( + t!("Current Control point").as_ref(), + "current_control_point", + index, + count, + ), + edit( + t!("Control point X").as_ref(), + "ctrl_pt_x", + point.map(|value| value.x).unwrap_or(0.0), + ), + edit( + t!("Control point Y").as_ref(), + "ctrl_pt_y", + point.map(|value| value.y).unwrap_or(0.0), + ), + edit( + t!("Control point Z").as_ref(), + "ctrl_pt_z", + point.map(|value| value.z).unwrap_or(0.0), + ), + edit(t!("Weight").as_ref(), "weight", weight), + ] }; + data_points.push(choice_prop( + t!("CV frame").as_ref(), + "cv_frame", + if spline.cv_frame_visible { "Show" } else { "Hide" }, + &["Hide", "Show"], + )); + + let mut misc = vec![ + choice_prop( + t!("Method").as_ref(), + "spline_method", + method, + &["Fit", "Control Vertices"], + ), + ro(t!("Degree").as_ref(), "degree", spline.degree.to_string()), + choice_prop( + t!("Closed").as_ref(), + "closed", + yes_no(closed), + &["No", "Yes"], + ), + ro( + t!("Periodic").as_ref(), + "periodic", + yes_no(spline.flags.periodic), + ), + ro( + t!("Planar").as_ref(), + "planar", + yes_no(is_planar(spline)), + ), + ]; + if fit_method { + misc.extend([ + edit( + t!("Start tangent vector X").as_ref(), + "start_tan_x", + spline.begin_tangent.x, + ), + edit( + t!("Start tangent vector Y").as_ref(), + "start_tan_y", + spline.begin_tangent.y, + ), + edit( + t!("Start tangent vector Z").as_ref(), + "start_tan_z", + spline.begin_tangent.z, + ), + edit( + t!("End tangent vector X").as_ref(), + "end_tan_x", + spline.end_tangent.x, + ), + edit( + t!("End tangent vector Y").as_ref(), + "end_tan_y", + spline.end_tangent.y, + ), + edit( + t!("End tangent vector Z").as_ref(), + "end_tan_z", + spline.end_tangent.z, + ), + edit( + t!("Fit tolerance").as_ref(), + "fit_tolerance", + spline.fit_tolerance, + ), + ]); + } vec![ PropSection { title: t!("Data Points").into_owned(), - props: vec![ - ro(t!("Show").as_ref(), "show", show), - ro(t!("Degree").as_ref(), "degree", spline.degree.to_string()), - ro( - t!("Control Point Count").as_ref(), - "ctrl_pt_count", - spline.control_points.len().to_string(), - ), - ro(t!("Control Point").as_ref(), "ctrl_pt_index", "0"), - edit( - t!("Control Point X").as_ref(), - "ctrl_pt_x", - cp0.map(|p| p.x).unwrap_or(0.0), - ), - edit( - t!("Control Point Y").as_ref(), - "ctrl_pt_y", - cp0.map(|p| p.y).unwrap_or(0.0), - ), - edit( - t!("Control Point Z").as_ref(), - "ctrl_pt_z", - cp0.map(|p| p.z).unwrap_or(0.0), - ), - edit(t!("Weight").as_ref(), "weight", w0.unwrap_or(1.0)), - ro(t!("Knot Parameterization").as_ref(), "knot_param", knot_param), - ro( - t!("Fit Point Count").as_ref(), - "fit_pt_count", - spline.fit_points.len().to_string(), - ), - ro(t!("Fit Point").as_ref(), "fit_pt_index", "0"), - edit(t!("Fit Point X").as_ref(), "fit_pt_x", fp0.map(|p| p.x).unwrap_or(0.0)), - edit(t!("Fit Point Y").as_ref(), "fit_pt_y", fp0.map(|p| p.y).unwrap_or(0.0)), - edit(t!("Fit Point Z").as_ref(), "fit_pt_z", fp0.map(|p| p.z).unwrap_or(0.0)), - edit(t!("Fit Tolerance").as_ref(), "fit_tolerance", spline.fit_tolerance), - edit(t!("Start Tangent X").as_ref(), "start_tan_x", spline.begin_tangent.x), - edit(t!("Start Tangent Y").as_ref(), "start_tan_y", spline.begin_tangent.y), - edit(t!("Start Tangent Z").as_ref(), "start_tan_z", spline.begin_tangent.z), - edit(t!("End Tangent X").as_ref(), "end_tan_x", spline.end_tangent.x), - edit(t!("End Tangent Y").as_ref(), "end_tan_y", spline.end_tangent.y), - edit(t!("End Tangent Z").as_ref(), "end_tan_z", spline.end_tangent.z), - ], + props: data_points, }, PropSection { title: t!("Misc").into_owned(), - props: vec![ - ro(t!("Closed").as_ref(), "closed", yes_no(closed)), - ro(t!("Planar").as_ref(), "planar", yes_no(spline.flags.planar)), - ro(t!("Length").as_ref(), "length", format!("{length:.4}")), - ro(t!("Area").as_ref(), "area", area), - ], + props: misc, }, ] } fn apply_geom_prop(spline: &mut Spline, field: &str, value: &str) { + match field { + "spline_method" => { + if value == "Fit" { + convert_to_fit_method(spline); + } else if value == "Control Vertices" { + convert_to_control_method(spline); + } + return; + } + "knot_param" => { + spline.knot_parameterization = match value { + "Chord" => 0, + "Square Root" => 1, + "Uniform" => 2, + "Custom" => 15, + _ => return, + }; + return; + } + "cv_frame" => { + spline.cv_frame_visible = value == "Show"; + return; + } + "closed" => { + spline.flags.closed = value == "Yes"; + if !spline.flags.closed { + spline.flags.periodic = false; + } + return; + } + _ => {} + } let Some(v) = parse_f64(value) else { return }; + let control_index = crate::scene::view::dispatch::prop_current_vertex() + .min(spline.control_points.len().saturating_sub(1)); + let fit_index = crate::scene::view::dispatch::prop_current_vertex() + .min(spline.fit_points.len().saturating_sub(1)); match field { "ctrl_pt_x" => { - if let Some(cp) = spline.control_points.first_mut() { + if let Some(cp) = spline.control_points.get_mut(control_index) { cp.x = v; } } "ctrl_pt_y" => { - if let Some(cp) = spline.control_points.first_mut() { + if let Some(cp) = spline.control_points.get_mut(control_index) { cp.y = v; } } "ctrl_pt_z" => { - if let Some(cp) = spline.control_points.first_mut() { + if let Some(cp) = spline.control_points.get_mut(control_index) { cp.z = v; } } "weight" => { - if let Some(w) = spline.weights.first_mut() { - *w = v; + if v > 0.0 && !spline.control_points.is_empty() { + if spline.weights.len() != spline.control_points.len() { + spline.weights = vec![1.0; spline.control_points.len()]; + } + spline.weights[control_index] = v; + spline.flags.rational = spline + .weights + .iter() + .any(|weight| (*weight - spline.weights[0]).abs() > 1e-12); } } "fit_pt_x" => { - if let Some(fp) = spline.fit_points.first_mut() { + if let Some(fp) = spline.fit_points.get_mut(fit_index) { fp.x = v; } } "fit_pt_y" => { - if let Some(fp) = spline.fit_points.first_mut() { + if let Some(fp) = spline.fit_points.get_mut(fit_index) { fp.y = v; } } "fit_pt_z" => { - if let Some(fp) = spline.fit_points.first_mut() { + if let Some(fp) = spline.fit_points.get_mut(fit_index) { fp.z = v; } } - "fit_tolerance" => spline.fit_tolerance = v, + "fit_tolerance" if v >= 0.0 => spline.fit_tolerance = v, "start_tan_x" => spline.begin_tangent.x = v, "start_tan_y" => spline.begin_tangent.y = v, "start_tan_z" => spline.begin_tangent.z = v, @@ -598,6 +851,7 @@ fn apply_geom_prop(spline: &mut Spline, field: &str, value: &str) { "end_tan_z" => spline.end_tangent.z = v, _ => {} } + spline.flags.planar = is_planar(spline); } fn apply_grip(spline: &mut Spline, grip_id: usize, apply: GripApply) { @@ -609,22 +863,9 @@ fn apply_grip(spline: &mut Spline, grip_id: usize, apply: GripApply) { // 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 { + if !convert_to_control_method(spline) { 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) @@ -674,7 +915,7 @@ impl crate::entities::traits::Grippable for Spline { 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 { + return if self.cv_frame_visible || self.fit_points.is_empty() { vec![ GripMenuItem { label: "Fit", @@ -724,9 +965,11 @@ impl crate::entities::traits::Grippable for Spline { ) { 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::ShowFit => { + if !self.fit_points.is_empty() || convert_to_fit_method(self) { + self.cv_frame_visible = false; + return; + } } A::ShowControlVertices if control_vertices(self).len() >= 2 => { self.cv_frame_visible = true; diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 18322e33b..fd5bf0ffc 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -577,6 +577,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Character Spacing" => Some(("properties", "character-spacing")), "Check your internet or proxy connection, then retry. Manually added repositories remain available." => Some(("plugins", "check-your-internet-or-proxy-connection-then-r")), "Choose New to capture the current layer settings." => Some(("layers", "choose-new-to-capture-the-current-layer-settin")), + "Chord" => Some(("properties", "chord")), "Chord X" => Some(("properties", "chord-x")), "Chord Y" => Some(("properties", "chord-y")), "Chord Z" => Some(("properties", "chord-z")), @@ -645,6 +646,10 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Control Point X" => Some(("properties", "control-point-x")), "Control Point Y" => Some(("properties", "control-point-y")), "Control Point Z" => Some(("properties", "control-point-z")), + "Control Vertices" => Some(("properties", "control-vertices")), + "Control point X" => Some(("properties", "control-point-x-sentence-case")), + "Control point Y" => Some(("properties", "control-point-y-sentence-case")), + "Control point Z" => Some(("properties", "control-point-z-sentence-case")), "Coordinates" => Some(("common", "coordinates")), "Coordination Model" => Some(("properties", "coordination-model")), "Copied path: {}" => Some(("common", "copied-path")), @@ -666,6 +671,9 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Current Color Scheme" => Some(("properties", "current-color-scheme")), "Current MLeader style: {}" => Some(("layers", "current-mleader-style")), "Current Vertex" => Some(("properties", "current-vertex-title-case")), + "Current Control point" => Some(("properties", "current-control-point")), + "Current Fit point" => Some(("properties", "current-fit-point")), + "CV frame" => Some(("properties", "cv-frame")), "Current annotation scale: {name}" => Some(("view", "current-annotation-scale-name")), "Current dim style set to '{}'." => Some(("common", "current-dim-style-set-to")), "Current layer" => Some(("layers", "current-layer")), @@ -961,6 +969,9 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "End Tangent X" => Some(("properties", "end-tangent-x")), "End Tangent Y" => Some(("properties", "end-tangent-y")), "End Tangent Z" => Some(("properties", "end-tangent-z")), + "End tangent vector X" => Some(("properties", "end-tangent-vector-x")), + "End tangent vector Y" => Some(("properties", "end-tangent-vector-y")), + "End tangent vector Z" => Some(("properties", "end-tangent-vector-z")), "End X" => Some(("properties", "end-x")), "End Y" => Some(("properties", "end-y")), "End Z" => Some(("properties", "end-z")), @@ -1066,6 +1077,10 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Fit Point Y" => Some(("properties", "fit-point-y")), "Fit Point Z" => Some(("properties", "fit-point-z")), "Fit Tolerance" => Some(("properties", "fit-tolerance")), + "Fit point X" => Some(("properties", "fit-point-x-sentence-case")), + "Fit point Y" => Some(("properties", "fit-point-y-sentence-case")), + "Fit point Z" => Some(("properties", "fit-point-z-sentence-case")), + "Fit tolerance" => Some(("properties", "fit-tolerance-sentence-case")), "Fit to paper" => Some(("plot", "fit-to-paper")), "Fit/Smooth" => Some(("properties", "fit-smooth-title-case")), "Fit/smooth" => Some(("properties", "fit-smooth-sentence-case")), @@ -1258,6 +1273,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Kilometers" => Some(("common", "kilometers")), "Kind" => Some(("model", "kind")), "Knot Parameterization" => Some(("properties", "knot-parameterization")), + "Knot parameterization" => Some(("properties", "knot-parameterization-sentence-case")), "LANDXMLIMPORT path to the .xml file:" => Some(("view", "landxmlimport-path-to-the-xml-file")), "LANDXMLIMPORT: cannot read \"{path}\": {e}" => Some(("view", "landxmlimport-cannot-read-path-e")), "LANDXMLIMPORT: imported {} survey point(s). Use ZOOM EXTENTS to view." => Some(("view", "landxmlimport-imported-survey-point-s-use-zoom")), @@ -1701,6 +1717,8 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Number of Grips" => Some(("properties", "number-of-grips")), "Number of Vertices" => Some(("properties", "number-of-vertices")), "Number of turns" => Some(("properties", "number-of-turns")), + "Number of control points" => Some(("properties", "number-of-control-points")), + "Number of fit points" => Some(("properties", "number-of-fit-points")), "OBJECTSCALE: added the active scale to {n} object(s)." => Some(("view", "objectscale-added-the-active-scale-to-n-object")), "OBJECTSCALE: select objects first." => Some(("view", "objectscale-select-objects-first")), "OBJECTSCALE: the active annotation scale is unavailable." => Some(("view", "objectscale-the-active-annotation-scale-is-una")), @@ -1905,6 +1923,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Pinned" => Some(("common", "pinned")), "Pitch And Family" => Some(("properties", "pitch-and-family")), "Place text manually (DIMUPT)" => Some(("common", "place-text-manually-dimupt")), + "Periodic" => Some(("properties", "periodic")), "Planar" => Some(("properties", "planar")), "Platform" => Some(("ui", "platform")), "Plot" => Some(("common", "plot")), @@ -2324,6 +2343,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Shortcut '{key}' not found." => Some(("view", "shortcut-key-not-found")), "Shortcut '{key}' removed." => Some(("view", "shortcut-key-removed")), "Shortcut set: {key} → {cmd_str}" => Some(("view", "shortcut-set-key-cmd-str")), + "Hide" => Some(("properties", "hide")), "Show" => Some(("properties", "show")), "Show Annotation Objects" => Some(("ui", "show-annotation-objects")), "Show Clipping" => Some(("properties", "show-clipping")), @@ -2369,6 +2389,10 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Start Tangent X" => Some(("properties", "start-tangent-x")), "Start Tangent Y" => Some(("properties", "start-tangent-y")), "Start Tangent Z" => Some(("properties", "start-tangent-z")), + "Start tangent vector X" => Some(("properties", "start-tangent-vector-x")), + "Start tangent vector Y" => Some(("properties", "start-tangent-vector-y")), + "Start tangent vector Z" => Some(("properties", "start-tangent-vector-z")), + "Square Root" => Some(("properties", "square-root")), "Start X" => Some(("properties", "start-x")), "Start Y" => Some(("properties", "start-y")), "Start Z" => Some(("properties", "start-z")), @@ -2769,6 +2793,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Usage: {prefix} NEW " => Some(("layers", "usage-prefix-new-name")), "Usage: {prefix} OBLIQUE