diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index d8a726a83..313cba46a 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 @@ -2352,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 @@ -2378,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 @@ -2385,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 @@ -2444,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 @@ -2481,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 @@ -2526,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 @@ -2618,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 @@ -2737,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 2608db7c2..b8d18e9c2 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çı @@ -2292,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 @@ -2318,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 @@ -2325,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ü @@ -2384,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 @@ -2421,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ığı @@ -2466,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ı @@ -2558,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 @@ -2677,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/mod.rs b/src/app/mod.rs index f9d749440..66d46acb3 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -82,6 +82,8 @@ pub struct GripPopup { pub anchor: iced::Point, pub items: Vec, pub selected: usize, + /// Whether a click-opened menu stays visible away from its grip. + pub pinned: bool, } /// Pending follow-up value for grip-menu actions that need a number diff --git a/src/app/properties.rs b/src/app/properties.rs index 21a5fbaea..9576564a4 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -121,6 +121,13 @@ 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 crate::entities::spline::uses_fit_method(spline) { + spline.fit_points.len() + } else { + spline.control_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..77949dd9c 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -1322,6 +1322,27 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } } // One-shot action — apply immediately. + let unchanged = self.tabs[i] + .scene + .document + .get_entity(popup.handle) + .is_some_and(|entity| match (item.action, entity) { + (GripMenuAction::ShowFit, acadrust::EntityType::Spline(spline)) => { + !spline.cv_frame_visible + && crate::entities::spline::uses_fit_method(spline) + } + ( + GripMenuAction::ShowControlVertices, + acadrust::EntityType::Spline(spline), + ) => { + spline.cv_frame_visible + || !crate::entities::spline::uses_fit_method(spline) + } + _ => false, + }); + if unchanged { + return Task::none(); + } self.push_undo_snapshot(i, item.label); // For Add Leader, the new arrow becomes the last grip; remember // its id so we can grab it for placement right after. @@ -1728,6 +1749,40 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { let handles = self.property_target_handles(i); if !handles.is_empty() { + if matches!(field, "spline_method" | "knot_param" | "cv_frame") { + let unchanged = handles.iter().all(|handle| { + let Some(acadrust::EntityType::Spline(spline)) = + self.tabs[i].scene.document.get_entity(*handle) + else { + return false; + }; + match field { + "spline_method" => { + value == if crate::entities::spline::uses_fit_method(spline) { + "Fit" + } else { + "Control Vertices" + } + } + "knot_param" => { + let current = match spline.knot_parameterization { + 0 => "Chord", + 1 => "Square Root", + 2 => "Uniform", + _ => "Custom", + }; + value == current + } + "cv_frame" => { + value == if spline.cv_frame_visible { "Show" } else { "Hide" } + } + _ => false, + } + }); + if unchanged { + return Task::none(); + } + } self.push_undo_snapshot(i, "CHPROP"); if field == "vscale_std" { @@ -2099,6 +2154,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 +2180,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/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..5acbd8cff 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -414,22 +414,24 @@ impl OpenCADStudio { None => tab.scene.active_model_tile_bounds(vw, vh), }; let sel_h = tab.selected_handle; - // The Current Vertex the Properties panel is focused on: - // mark that grip hot so the navigated vertex is visible in - // the drawing. Only for a single selected polyline, whose - // vertex grips are ids 0..n. (Properties vertex stepper) + // Mark the Properties panel's current point grip hot. let current_vertex_grip: Option = tab .properties .prop_vertex_indicator_active .then(|| sel_h) .flatten() .and_then(|h| { - matches!( - tab.scene.document.get_entity(h), - Some(acadrust::EntityType::LwPolyline(_)) - | Some(acadrust::EntityType::Polyline2D(_)) - ) - .then_some(tab.properties.prop_vertex) + let indexed = match tab.scene.document.get_entity(h) { + Some(acadrust::EntityType::LwPolyline(_)) + | Some(acadrust::EntityType::Polyline2D(_)) => true, + Some(acadrust::EntityType::Spline(spline)) => { + !crate::entities::spline::uses_fit_method(spline) + || !spline.cv_frame_visible + || crate::entities::curve::spline_curve(spline).is_none() + } + _ => false, + }; + indexed.then_some(tab.properties.prop_vertex) }); // In-viewport grips are model-space; project them with the // viewport camera so they sit on the wire the GPU draws. @@ -495,6 +497,30 @@ 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, + }; + if tab + .selected_grip_handles + .iter() + .any(|owner| *owner != handle) + { + 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 +714,7 @@ impl OpenCADStudio { snap_ext_base, snap_ext_base2, grips, + control_polygon, grip_clip, ucs_icons, ost_points, @@ -1092,26 +1119,28 @@ impl OpenCADStudio { } } - // Multi-functional grip popup (Phase 2). One bordered container - // wraps a column of borderless item buttons so the popup reads - // as a single widget instead of stacked tiles. if let Some(popup) = self.grip_popup.as_ref() { if !tab.is_start { - // Size the row to the widest label so the selection - // highlight fills the whole row instead of just the - // text glyphs. ~7 px per character at size 12 + the - // horizontal padding (10 + 10). - let max_len = popup + let labels: Vec = popup .items .iter() - .map(|i| i.label.chars().count()) + .map(|item| { + let (mark, raw) = item + .label + .strip_prefix("✓ ") + .map_or(("", item.label), |raw| ("✓ ", raw)); + format!("{mark}{}", crate::i18n::translate(raw)) + }) + .collect(); + let max_len = labels + .iter() + .map(|label| label.chars().count()) .max() .unwrap_or(8) as f32; let row_w = max_len * 7.0 + 24.0; let mut col = column![].spacing(0).width(iced::Length::Fixed(row_w)); - for (idx, item) in popup.items.iter().enumerate() { + for (idx, label) in labels.into_iter().enumerate() { let is_sel = idx == popup.selected; - let label = item.label; let btn = button(text(label).size(12)) .on_press(Message::GripMenuPick(idx)) .padding([3, 10]) diff --git a/src/entities/common.rs b/src/entities/common.rs index 89d55ade5..4542591ca 100644 --- a/src/entities/common.rs +++ b/src/entities/common.rs @@ -453,6 +453,30 @@ pub fn circle_grip(id: usize, world: glam::DVec3) -> GripDef { } } +/// Round grip that edits one vertex. +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/curve.rs b/src/entities/curve.rs index 23116cc41..d30077733 100644 --- a/src/entities/curve.rs +++ b/src/entities/curve.rs @@ -287,12 +287,12 @@ pub fn polyline2d_curve(polyline: &Polyline2D) -> Option { /// plane the normal describes. A spline that genuinely wanders in space gets /// `None`, which is honest: flattening it to XY would move it. pub fn spline_curve(spline: &SplineEnt) -> Option { - let points: Vec = spline - .control_points - .iter() - .chain(spline.fit_points.iter()) - .copied() - .collect(); + let source = if crate::entities::spline::uses_fit_method(spline) { + &spline.fit_points + } else { + &spline.control_points + }; + let points: Vec = source.to_vec(); let first = points.first()?; let normal = normalized(spline.normal); let elevation = Vec3::from(xyz(*first)).dot(Vec3::from(xyz(normal))); diff --git a/src/entities/spline.rs b/src/entities/spline.rs index 00d9b91f6..9a4f5d035 100644 --- a/src/entities/spline.rs +++ b/src/entities/spline.rs @@ -1,12 +1,20 @@ use acadrust::entities::Spline; use crate::t; +use cadkernel::geom2d::Curve as KernelCurve; 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}; +use crate::scene::model::object::{GripApply, GripDef, PropSection, PropValue, Property}; + +pub(crate) fn uses_fit_method(spline: &Spline) -> bool { + let degree = spline.degree.max(1) as usize; + !spline.fit_points.is_empty() && spline.control_points.len() <= degree +} fn to_render(spl: &Spline) -> RenderEntity { let n = spl.control_points.len(); @@ -86,22 +94,24 @@ fn to_render(spl: &Spline) -> RenderEntity { let (snap_pts, key_vertices) = match curve_snap { Some(snap) => { let mut points = snap.snap_pts; - points.extend(spl.fit_points.iter().map(|p| { - ( - glam::DVec3::new(p.x, p.y, p.z), - crate::scene::model::wire_model::SnapHint::Endpoint, - ) - })); + if uses_fit_method(spl) { + points.extend(spl.fit_points.iter().map(|p| { + ( + glam::DVec3::new(p.x, p.y, p.z), + crate::scene::model::wire_model::SnapHint::Endpoint, + ) + })); + } (points, Vec::new()) } // A spline through points in space is not a planar curve, so the // kernel has nothing to say about it. Its stored points remain the // only thing to offer. None => { - let source = if spl.fit_points.is_empty() { - &spl.control_points - } else { + let source = if uses_fit_method(spl) { &spl.fit_points + } else { + &spl.control_points }; ( Vec::new(), @@ -141,10 +151,9 @@ fn to_render(spl: &Spline) -> RenderEntity { } pub(crate) fn measurement_polyline(spl: &Spline) -> Vec<[f64; 3]> { - let count = spl.control_points.len(); - if count < 2 { + if uses_fit_method(spl) { if spl.fit_points.len() < 2 { - return spl.control_points.iter().map(|p| [p.x, p.y, p.z]).collect(); + return spl.fit_points.iter().map(|p| [p.x, p.y, p.z]).collect(); } return if spl.flags.closed || spl.flags.periodic { catmull_rom_polyline(&spl.fit_points, true) @@ -153,6 +162,11 @@ pub(crate) fn measurement_polyline(spl: &Spline) -> Vec<[f64; 3]> { }; } + let count = spl.control_points.len(); + if count < 2 { + return spl.control_points.iter().map(|p| [p.x, p.y, p.z]).collect(); + } + let degree = spl.degree.max(0) as usize; if degree == 0 || degree >= count { return spl.control_points.iter().map(|p| [p.x, p.y, p.z]).collect(); @@ -261,6 +275,60 @@ fn fit_spline_polyline(spl: &Spline) -> Vec<[f64; 3]> { return p; } + let Some((h, slopes)) = fit_spline_slopes(spl, &p) else { + return p; + }; + + // Evaluate each segment as a cubic Hermite (dP/du = slope · h_i). + let mut out = Vec::new(); + for i in 0..n - 1 { + let point_at = |u: f64| { + let (u2, u3) = (u * u, u * u * u); + let h00 = 2.0 * u3 - 3.0 * u2 + 1.0; + let h10 = u3 - 2.0 * u2 + u; + let h01 = -2.0 * u3 + 3.0 * u2; + let h11 = u3 - u2; + let mut q = [0.0f64; 3]; + for k in 0..3 { + let m0 = slopes[k][i] * h[i]; + let m1 = slopes[k][i + 1] * h[i]; + q[k] = h00 * p[i][k] + h10 * m0 + h01 * p[i + 1][k] + h11 * m1; + } + q + }; + let tangent_at = |u: f64| { + let u2 = u * u; + let (h00, h10, h01, h11) = ( + 6.0 * u2 - 6.0 * u, + 3.0 * u2 - 4.0 * u + 1.0, + -6.0 * u2 + 6.0 * u, + 3.0 * u2 - 2.0 * u, + ); + let mut q = [0.0; 3]; + for k in 0..3 { + let m0 = slopes[k][i] * h[i]; + let m1 = slopes[k][i + 1] * h[i]; + q[k] = h00 * p[i][k] + h10 * m0 + h01 * p[i + 1][k] + h11 * m1; + } + q + }; + let sampled = cadkernel::tessellation::sample_curve3_angle( + point_at, + tangent_at, + cadkernel::tessellation::DEFAULT_ANGLE, + ); + out.extend(sampled.into_iter().skip(usize::from(i > 0))); + } + out +} + +/// Slopes used by spatial fit interpolation. +fn fit_spline_slopes(spl: &Spline, p: &[[f64; 3]]) -> Option<(Vec, [Vec; 3])> { + let n = p.len(); + if n < 2 { + return None; + } + // Parameterise the fit points (unnormalised, so a unit end tangent — how the // tangents are stored — is a consistent dP/dt). Match the spline's knot // parameterisation: 2 = uniform, 1 = centripetal (√chord), else chord. @@ -329,48 +397,7 @@ fn fit_spline_polyline(spl: &Spline) -> Vec<[f64; 3]> { thomas_solve(&a, &b, &c, &mut d); slopes[k] = d; } - - // Evaluate each segment as a cubic Hermite (dP/du = slope · h_i). - let mut out = Vec::new(); - for i in 0..n - 1 { - let point_at = |u: f64| { - let (u2, u3) = (u * u, u * u * u); - let h00 = 2.0 * u3 - 3.0 * u2 + 1.0; - let h10 = u3 - 2.0 * u2 + u; - let h01 = -2.0 * u3 + 3.0 * u2; - let h11 = u3 - u2; - let mut q = [0.0f64; 3]; - for k in 0..3 { - let m0 = slopes[k][i] * h[i]; - let m1 = slopes[k][i + 1] * h[i]; - q[k] = h00 * p[i][k] + h10 * m0 + h01 * p[i + 1][k] + h11 * m1; - } - q - }; - let tangent_at = |u: f64| { - let u2 = u * u; - let (h00, h10, h01, h11) = ( - 6.0 * u2 - 6.0 * u, - 3.0 * u2 - 4.0 * u + 1.0, - -6.0 * u2 + 6.0 * u, - 3.0 * u2 - 2.0 * u, - ); - let mut q = [0.0; 3]; - for k in 0..3 { - let m0 = slopes[k][i] * h[i]; - let m1 = slopes[k][i + 1] * h[i]; - q[k] = h00 * p[i][k] + h10 * m0 + h01 * p[i + 1][k] + h11 * m1; - } - q - }; - let sampled = cadkernel::tessellation::sample_curve3_angle( - point_at, - tangent_at, - cadkernel::tessellation::DEFAULT_ANGLE, - ); - out.extend(sampled.into_iter().skip(usize::from(i > 0))); - } - out + Some((h, slopes)) } /// In-place Thomas solve for a tridiagonal system (`a` sub-, `b` main-, `c` @@ -390,189 +417,482 @@ fn thomas_solve(a: &[f64], b: &[f64], c: &[f64], d: &mut [f64]) { } } -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 - } else { - &spline.control_points +pub(crate) const SPLINE_MODE_GRIP_ID: usize = usize::MAX - 1; + +fn control_vertices(spline: &Spline) -> Vec { + if !uses_fit_method(spline) { + return spline.control_points.clone(); + } + let Some(planar) = crate::entities::curve::spline_curve(spline) else { + return Vec::new(); }; - source + let KernelCurve::Nurbs(curve) = &planar.curve else { + return Vec::new(); + }; + curve + .control_points() .iter() - .enumerate() - .map(|(i, p)| square_grip(i, glam::DVec3::new(p.x, p.y, p.z))) + .map(|point| { + let point = planar.plane.point_at(*point); + acadrust::types::Vector3::new(point[0], point[1], point[2]) + }) .collect() } -fn properties(spline: &Spline) -> Vec { - let show = if spline.fit_points.is_empty() { - "Control Vertices" - } else { - "Fit Points" - }; - let cp0 = spline.control_points.first(); - let fp0 = spline.fit_points.first(); - let w0 = spline.weights.first().copied(); +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(), + }, + } +} - // Polyline-approximation length over the effective defining points. - let pts = if spline.fit_points.is_empty() { - &spline.control_points - } else { - &spline.fit_points +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 !uses_fit_method(spline) { + return true; + } + let Some(planar) = crate::entities::curve::spline_curve(spline) else { + return false; }; - 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 KernelCurve::Nurbs(curve) = &planar.curve else { + return false; + }; + spline.degree = curve.degree() as i32; + spline.knots = curve.knots().to_vec(); + spline.control_points = curve + .control_points() + .iter() + .map(|point| { + let point = planar.plane.point_at(*point); + acadrust::types::Vector3::new(point[0], point[1], point[2]) + }) + .collect(); + spline.weights = if curve.is_rational() { + curve.weights().to_vec() + } else { + Vec::new() + }; + spline.flags.rational = curve.is_rational(); + spline.flags.planar = true; + 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 { + uses_fit_method(spline) +} + +fn is_planar(spline: &Spline) -> bool { + crate::entities::curve::spline_curve(spline).is_some() +} + +fn tangent_is_set(tangent: &acadrust::types::Vector3) -> bool { + tangent.x * tangent.x + tangent.y * tangent.y + tangent.z * tangent.z > 1e-18 +} + +/// Stored or kernel-derived fit-curve end tangents. +fn effective_fit_tangents( + spline: &Spline, +) -> (acadrust::types::Vector3, acadrust::types::Vector3) { + let begin_set = tangent_is_set(&spline.begin_tangent); + let end_set = tangent_is_set(&spline.end_tangent); + if begin_set && end_set { + return (spline.begin_tangent, spline.end_tangent); } - let closed = spline.flags.closed || spline.flags.periodic; - let yes_no = |b: bool| if b { "Yes" } else { "No" }; + let derived = crate::entities::curve::spline_curve(spline).and_then(|planar| { + let KernelCurve::Nurbs(curve) = &planar.curve else { + return None; + }; + let (start, end) = curve.domain(); + Some(( + planar.plane.vector_at(curve.derivative_at_knot(start)), + planar.plane.vector_at(curve.derivative_at_knot(end)), + )) + }); - // Knot parameterization method (R2013+ DWG); older splines report 0. + let Some((derived_begin, derived_end)) = derived else { + return (spline.begin_tangent, spline.end_tangent); + }; + let vector = |value: [f64; 3]| { + acadrust::types::Vector3::new(value[0], value[1], value[2]) + }; + ( + if begin_set { + spline.begin_tangent + } else { + vector(derived_begin) + }, + if end_set { + spline.end_tangent + } else { + vector(derived_end) + }, + ) +} + +fn grips(spline: &Spline) -> Vec { + let fit_method = uses_fit_method(spline); + let derived_control = control_vertices(spline); + let show_control_vertices = (spline.cv_frame_visible || !fit_method) + && derived_control.len() >= 2; + let source = if show_control_vertices { + derived_control + } else if fit_method { + spline.fit_points.clone() + } else { + spline.control_points.clone() + }; + let mut grips: Vec<_> = source + .iter() + .enumerate() + .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 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 fit_method = uses_fit_method(spline); + let method = if fit_method { "Fit" } else { "Control Vertices" }; + let closed = spline.flags.closed || spline.flags.periodic; + let can_show_control = control_vertices(spline).len() >= 2; + let yes_no = |b: bool| if b { "Yes" } else { "No" }; 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 (effective_begin_tangent, effective_end_tangent) = effective_fit_tangents(spline); + 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), + ), + if matches!(spline.knot_parameterization, 0..=2) { + choice_prop( + t!("Knot parameterization").as_ref(), + "knot_param", + knot_param, + &["Chord", "Square Root", "Uniform"], + ) + } else { + ro( + t!("Knot parameterization").as_ref(), + "knot_param", + crate::i18n::translate(knot_param).into_owned(), + ) + }, + ] } 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(if can_show_control { + choice_prop( + t!("CV frame").as_ref(), + "cv_frame", + if spline.cv_frame_visible { "Show" } else { "Hide" }, + &["Hide", "Show"], + ) + } else { + ro(t!("CV frame").as_ref(), "cv_frame", t!("Hide").into_owned()) + }); + + let method_prop = if fit_method && can_show_control { + choice_prop( + t!("Method").as_ref(), + "spline_method", + method, + &["Fit", "Control Vertices"], + ) + } else { + ro( + t!("Method").as_ref(), + "spline_method", + crate::i18n::translate(method).into_owned(), + ) + }; + let mut misc = vec![ + method_prop, + ro(t!("Degree").as_ref(), "degree", spline.degree.to_string()), + ro( + t!("Closed").as_ref(), + "closed", + crate::i18n::translate(yes_no(closed)).into_owned(), + ), + ro( + t!("Periodic").as_ref(), + "periodic", + crate::i18n::translate(yes_no(spline.flags.periodic)).into_owned(), + ), + ro( + t!("Planar").as_ref(), + "planar", + crate::i18n::translate(yes_no(is_planar(spline))).into_owned(), + ), + ]; + if fit_method { + misc.extend([ + edit( + t!("Start tangent vector X").as_ref(), + "start_tan_x", + effective_begin_tangent.x, + ), + edit( + t!("Start tangent vector Y").as_ref(), + "start_tan_y", + effective_begin_tangent.y, + ), + edit( + t!("Start tangent vector Z").as_ref(), + "start_tan_z", + effective_begin_tangent.z, + ), + edit( + t!("End tangent vector X").as_ref(), + "end_tan_x", + effective_end_tangent.x, + ), + edit( + t!("End tangent vector Y").as_ref(), + "end_tan_y", + effective_end_tangent.y, + ), + edit( + t!("End tangent vector Z").as_ref(), + "end_tan_z", + effective_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 == "Control Vertices" { + convert_to_control_method(spline); + } + return; + } + "knot_param" => { + spline.knot_parameterization = match value { + "Chord" => 0, + "Square Root" => 1, + "Uniform" => 2, + _ => return, + }; + return; + } + "cv_frame" => { + spline.cv_frame_visible = value == "Show" && control_vertices(spline).len() >= 2; + return; + } + _ => {} + } + let (effective_begin_tangent, effective_end_tangent) = effective_fit_tangents(spline); 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, - "start_tan_x" => spline.begin_tangent.x = v, - "start_tan_y" => spline.begin_tangent.y = v, - "start_tan_z" => spline.begin_tangent.z = v, - "end_tan_x" => spline.end_tangent.x = v, - "end_tan_y" => spline.end_tangent.y = v, - "end_tan_z" => spline.end_tangent.z = v, + "fit_tolerance" if v >= 0.0 => spline.fit_tolerance = v, + "start_tan_x" | "start_tan_y" | "start_tan_z" => { + if !tangent_is_set(&spline.begin_tangent) { + spline.begin_tangent = effective_begin_tangent; + } + match field { + "start_tan_x" => spline.begin_tangent.x = v, + "start_tan_y" => spline.begin_tangent.y = v, + "start_tan_z" => spline.begin_tangent.z = v, + _ => {} + } + } + "end_tan_x" | "end_tan_y" | "end_tan_z" => { + if !tangent_is_set(&spline.end_tangent) { + spline.end_tangent = effective_end_tangent; + } + match field { + "end_tan_x" => spline.end_tangent.x = v, + "end_tan_y" => spline.end_tangent.y = v, + "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) { - // 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; + } + // First CV edit makes the displayed kernel controls authoritative. + let fit_method = uses_fit_method(spline); + if spline.cv_frame_visible && fit_method { + if !convert_to_control_method(spline) { + return; + } + } + let target = if spline.cv_frame_visible || !fit_method { spline.control_points.get_mut(grip_id) + } else { + spline.fit_points.get_mut(grip_id) }; if let Some(cp) = target { match apply { @@ -614,96 +934,62 @@ 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}; - vec![ - GripMenuItem { - label: "Stretch", - action: GripMenuAction::Stretch, - }, - GripMenuItem { - label: "Add Vertex", - action: GripMenuAction::AddVertex, - }, - GripMenuItem { - label: "Remove Vertex", - action: GripMenuAction::RemoveVertex, - }, - GripMenuItem { - label: "Refine Vertices", - action: GripMenuAction::RefineVertices, - }, - ] + if grip_id == SPLINE_MODE_GRIP_ID { + let fit_method = uses_fit_method(self); + let can_show_control = control_vertices(self).len() >= 2; + return if self.cv_frame_visible || !fit_method { + vec![ + (can_show_control && fit_method).then_some(GripMenuItem { + label: "Fit", + action: GripMenuAction::ShowFit, + }), + can_show_control.then_some(GripMenuItem { + label: "✓ Control Vertices", + action: GripMenuAction::ShowControlVertices, + }), + ] + .into_iter() + .flatten() + .collect() + } else { + vec![ + Some(GripMenuItem { + label: "✓ Fit", + action: GripMenuAction::ShowFit, + }), + can_show_control.then_some(GripMenuItem { + label: "Control Vertices", + action: GripMenuAction::ShowControlVertices, + }), + ] + .into_iter() + .flatten() + .collect() + }; + } + vec![GripMenuItem { + label: "Stretch", + action: GripMenuAction::Stretch, + }] } fn apply_grip_menu( &mut self, - grip_id: usize, + _grip_id: usize, action: crate::scene::model::object::GripMenuAction, ) { use crate::scene::model::object::GripMenuAction as A; - let n = self.control_points.len(); - let min_cv = (self.degree as usize).saturating_add(1).max(2); match action { - A::AddVertex if grip_id < n => { - let i1 = (grip_id + 1).min(n - 1); - if i1 == grip_id { + A::ShowFit => { + if convert_to_fit_method(self) { + self.cv_frame_visible = false; return; } - let p0 = &self.control_points[grip_id]; - let p1 = &self.control_points[i1]; - let mid = acadrust::types::Vector3::new( - (p0.x + p1.x) * 0.5, - (p0.y + p1.y) * 0.5, - (p0.z + p1.z) * 0.5, - ); - self.control_points.insert(i1, mid); - if !self.weights.is_empty() && self.weights.len() == n { - let w = (self.weights[grip_id] + self.weights[i1.min(self.weights.len() - 1)]) - * 0.5; - self.weights.insert(i1, w); - } - // Clear knots so to_render rebuilds a uniform knot vector - // for the new CV count. - self.knots.clear(); } - A::RemoveVertex if grip_id < n && n > min_cv => { - self.control_points.remove(grip_id); - if grip_id < self.weights.len() { - self.weights.remove(grip_id); - } - self.knots.clear(); - } - A::RefineVertices => { - // Insert a CV between every adjacent pair (chord midpoints) - // and rebuild a uniform knot vector. - if n >= 2 { - let mut refined = Vec::with_capacity(n * 2 - 1); - let mut refined_w = Vec::with_capacity(n * 2 - 1); - let has_w = !self.weights.is_empty() && self.weights.len() == n; - for i in 0..n { - refined.push(self.control_points[i].clone()); - if has_w { - refined_w.push(self.weights[i]); - } - if i + 1 < n { - let a = &self.control_points[i]; - let b = &self.control_points[i + 1]; - refined.push(acadrust::types::Vector3::new( - (a.x + b.x) * 0.5, - (a.y + b.y) * 0.5, - (a.z + b.z) * 0.5, - )); - if has_w { - refined_w.push((self.weights[i] + self.weights[i + 1]) * 0.5); - } - } - } - self.control_points = refined; - if has_w { - self.weights = refined_w; - } - self.knots.clear(); - } + A::ShowControlVertices if control_vertices(self).len() >= 2 => { + self.cv_frame_visible = true; + return; } _ => {} } diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 832468a18..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")), @@ -2192,6 +2211,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")), @@ -2323,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")), @@ -2368,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")), @@ -2768,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