From b3c8c0a2f166b4401afc4ce6503681fedd143d64 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 8 Jul 2026 17:53:05 +0300 Subject: [PATCH] feat(properties): navigate polyline vertices from the panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Current Vertex row is now a ◀ / ▶ stepper (new PropValue::Stepper) showing "i / N". Stepping it moves the panel's focus through the polyline's vertices — Vertex X/Y and the per-vertex start/end widths show and edit the focused vertex instead of always the first — and the focused vertex's grip is drawn hot (filled) in the drawing so it's visible while navigating. Focus wraps around and resets to the first vertex when the selection changes. Adds Area and Length to the LwPolyline Geometry group (Polyline2D already had them). Applies to LwPolyline and Polyline2D. The focused vertex is threaded to the per-entity property builder/editor through a dispatch thread-local (like the curve-tolerance override), so the PropertyEditable trait signatures are unchanged. Co-Authored-By: Claude Opus 4.8 --- src/app/mod.rs | 2 ++ src/app/properties.rs | 17 +++++++++++++++ src/app/update/command.rs | 5 +++++ src/app/update/mod.rs | 23 ++++++++++++++++++++ src/app/view/mod.rs | 15 ++++++++++++- src/entities/common.rs | 17 +++++++++++++++ src/entities/lwpolyline.rs | 43 +++++++++++++++++++++++++++++--------- src/entities/polyline.rs | 41 ++++++++++++++++++++++++++---------- src/scene/model/object.rs | 3 +++ src/scene/selection.rs | 1 + src/scene/view/dispatch.rs | 20 ++++++++++++++++++ src/ui/properties.rs | 43 ++++++++++++++++++++++++++++++++++++++ 12 files changed, 208 insertions(+), 22 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 1b7358b2..69ec0090 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1526,6 +1526,8 @@ pub enum Message { PropLinetypeChanged(String), /// User toggled a boolean property (e.g. Invisible). PropBoolToggle(&'static str), + /// User stepped the Current Vertex selector by ±1 (polyline vertex nav). + PropVertexStep(i8), /// User selected a hatch pattern from the pattern pick_list in Properties. PropHatchPatternChanged(String), /// User selected a generic choice field in the Properties panel. diff --git a/src/app/properties.rs b/src/app/properties.rs index 3171f733..dc4ab7d4 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -64,6 +64,22 @@ impl OpenCADStudio { .filter(|name| !name.is_empty()) .collect(); + // Current-Vertex focus survives only while the same object stays + // selected; a changed selection resets to the first vertex. Seed the + // per-thread focus so the polyline property builder / editor targets it. + let cur_handles: Vec = self.tabs[i] + .scene + .selected_entities() + .iter() + .map(|(h, _)| *h) + .collect(); + let prop_vertex = if cur_handles == prev_handles { + self.tabs[i].properties.prop_vertex + } else { + 0 + }; + crate::scene::view::dispatch::set_prop_current_vertex(prop_vertex); + let new_panel = { let selected = self.tabs[i].scene.selected_entities(); let mut panel = match selected.len() { @@ -475,6 +491,7 @@ impl OpenCADStudio { Default::default() }; panel.source_handles = new_handles; + panel.prop_vertex = prop_vertex; panel }; diff --git a/src/app/update/command.rs b/src/app/update/command.rs index a6a9c1eb..a2876350 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -1278,6 +1278,11 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } } } else { + // Per-vertex geometry edits (vertex_x/y, widths) target + // the vertex the Current Vertex stepper is on. (polyline) + crate::scene::view::dispatch::set_prop_current_vertex( + self.tabs[i].properties.prop_vertex, + ); for &handle in &handles { // Skip objects on a locked layer. if self.tabs[i].scene.is_layer_locked(handle) { diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 34ebe8e8..47e42f88 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -2200,6 +2200,29 @@ impl OpenCADStudio { Task::none() } + Message::PropVertexStep(delta) => { + let i = self.active_tab; + let handles = self.property_target_handles(i); + // Vertex navigation applies to a single selected polyline. + let n = if handles.len() == 1 { + match self.tabs[i].scene.document.get_entity(handles[0]) { + Some(acadrust::EntityType::LwPolyline(p)) => p.vertices.len(), + Some(acadrust::EntityType::Polyline2D(p)) => p.vertices.len(), + _ => 0, + } + } else { + 0 + }; + if n > 0 { + let cur = self.tabs[i].properties.prop_vertex.min(n - 1) as i64; + // Wrap around so ◀ from the first vertex lands on the last. + let next = (cur + delta as i64).rem_euclid(n as i64) as usize; + self.tabs[i].properties.prop_vertex = next; + self.refresh_properties(); + } + Task::none() + } + Message::PropGeomChoiceChanged { field, value } => { self.on_prop_geom_choice_changed(field, value) } diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 04ff8c42..f0c3a6b0 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -219,6 +219,18 @@ 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) + let current_vertex_grip: Option = sel_h.and_then(|h| { + matches!( + tab.scene.document.get_entity(h), + Some(acadrust::EntityType::LwPolyline(_)) + | Some(acadrust::EntityType::Polyline2D(_)) + ) + .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. // Paper entities use the 2-D paper transform; the model tab @@ -257,7 +269,8 @@ impl OpenCADStudio { let is_hot = tab .active_grip .as_ref() - .map_or(false, |g| Some(g.handle) == sel_h && g.grip_id == grip_id); + .map_or(false, |g| Some(g.handle) == sel_h && g.grip_id == grip_id) + || Some(grip_id) == current_vertex_grip; crate::ui::overlay::GripMarker { pos: screen, shape, diff --git a/src/entities/common.rs b/src/entities/common.rs index adb63a81..0e337fc8 100644 --- a/src/entities/common.rs +++ b/src/entities/common.rs @@ -181,6 +181,23 @@ pub fn ro_prop(label: &'static str, field: &'static str, value: impl Into, +) -> Property { + Property { + label: label.into(), + field, + value: PropValue::Stepper { + field, + display: display.into(), + }, + } +} + pub fn parse_f64(value: &str) -> Option { value.trim().parse::().ok() } diff --git a/src/entities/lwpolyline.rs b/src/entities/lwpolyline.rs index c1c3d299..b56f1a05 100644 --- a/src/entities/lwpolyline.rs +++ b/src/entities/lwpolyline.rs @@ -4,6 +4,7 @@ use truck_modeling::{builder, Edge, Point3, Wire}; use crate::command::EntityTransform; use crate::entities::common::{ edit_prop as edit, parse_f64, rectangle_grip, ro_prop as ro, square_grip, + stepper_prop as stepper, }; use crate::entities::traits::TruckConvertible; use crate::scene::convert::acad_to_truck::{TruckEntity, TruckObject}; @@ -323,22 +324,37 @@ fn grips(pline: &LwPolyline) -> Vec { } fn properties(pline: &LwPolyline) -> Vec { - let v0 = pline.vertices.first(); - let vx = v0.map_or(0.0, |v| v.location.x); - let vy = v0.map_or(0.0, |v| v.location.y); - let start_w = v0.map_or(0.0, |v| v.start_width); - let end_w = v0.map_or(0.0, |v| v.end_width); + let n = pline.vertices.len(); + // The panel's Current Vertex focus, clamped to this polyline's range. + let vi = if n == 0 { + 0 + } else { + crate::scene::view::dispatch::prop_current_vertex().min(n - 1) + }; + let v = pline.vertices.get(vi); + let vx = v.map_or(0.0, |v| v.location.x); + let vy = v.map_or(0.0, |v| v.location.y); + let start_w = v.map_or(0.0, |v| v.start_width); + let end_w = v.map_or(0.0, |v| v.end_width); + let mp = ::mass_props(pline); + let vertex_label = if n == 0 { + "—".to_string() + } else { + format!("{} / {}", vi + 1, n) + }; vec![ PropSection { title: "Geometry".into(), props: vec![ - ro("Current Vertex", "current_vertex", String::new()), + stepper("Current Vertex", "current_vertex", vertex_label), edit("Vertex X", "vertex_x", vx), edit("Vertex Y", "vertex_y", vy), edit("Start segment width", "start_width", start_w), edit("End segment width", "end_width", end_w), edit("Global width", "global_width", pline.constant_width), edit("Elevation", "elevation", pline.elevation), + ro("Area", "area", format!("{:.4}", mp.area)), + ro("Length", "length", format!("{:.4}", mp.perimeter)), ], }, PropSection { @@ -388,26 +404,33 @@ fn apply_geom_prop(pline: &mut LwPolyline, field: &str, value: &str) { let Some(v) = parse_f64(value) else { return; }; + // Per-vertex edits target the vertex the panel is focused on. + let n = pline.vertices.len(); + let vi = if n == 0 { + 0 + } else { + crate::scene::view::dispatch::prop_current_vertex().min(n - 1) + }; match field { "elevation" => pline.elevation = v, "global_width" => pline.constant_width = v, "vertex_x" => { - if let Some(vtx) = pline.vertices.first_mut() { + if let Some(vtx) = pline.vertices.get_mut(vi) { vtx.location.x = v; } } "vertex_y" => { - if let Some(vtx) = pline.vertices.first_mut() { + if let Some(vtx) = pline.vertices.get_mut(vi) { vtx.location.y = v; } } "start_width" => { - if let Some(vtx) = pline.vertices.first_mut() { + if let Some(vtx) = pline.vertices.get_mut(vi) { vtx.start_width = v; } } "end_width" => { - if let Some(vtx) = pline.vertices.first_mut() { + if let Some(vtx) = pline.vertices.get_mut(vi) { vtx.end_width = v; } } diff --git a/src/entities/polyline.rs b/src/entities/polyline.rs index a0b33cc2..ebb00103 100644 --- a/src/entities/polyline.rs +++ b/src/entities/polyline.rs @@ -2,7 +2,9 @@ use acadrust::entities::{Polyline, Polyline2D, Polyline3D}; use truck_modeling::{builder, Edge, Point3, Wire}; use crate::command::EntityTransform; -use crate::entities::common::{edit_prop as edit, parse_f64, ro_prop as ro, square_grip}; +use crate::entities::common::{ + edit_prop as edit, parse_f64, ro_prop as ro, square_grip, stepper_prop as stepper, +}; use crate::entities::traits::{Grippable, PropertyEditable, Transformable, TruckConvertible}; use crate::scene::convert::acad_to_truck::{TruckEntity, TruckObject}; use crate::scene::model::object::{GripApply, GripDef, PropSection, PropValue, Property}; @@ -399,17 +401,27 @@ impl PropertyEditable for Polyline2D { } area = (area * 0.5).abs(); - let v0 = self.vertices.first(); - let vertex_x = v0.map(|v| v.location.x).unwrap_or_default(); - let vertex_y = v0.map(|v| v.location.y).unwrap_or_default(); - let seg_start_w = v0.map(|v| v.start_width).unwrap_or_default(); - let seg_end_w = v0.map(|v| v.end_width).unwrap_or_default(); + let vi = if n == 0 { + 0 + } else { + crate::scene::view::dispatch::prop_current_vertex().min(n - 1) + }; + let v = self.vertices.get(vi); + let vertex_x = v.map(|v| v.location.x).unwrap_or_default(); + let vertex_y = v.map(|v| v.location.y).unwrap_or_default(); + let seg_start_w = v.map(|v| v.start_width).unwrap_or_default(); + let seg_end_w = v.map(|v| v.end_width).unwrap_or_default(); + let vertex_label = if n == 0 { + "—".to_string() + } else { + format!("{} / {}", vi + 1, n) + }; vec![ PropSection { title: "Geometry".into(), props: vec![ - ro("Current Vertex", "pl2_current_vertex", if n > 0 { "1" } else { "" }), + stepper("Current Vertex", "pl2_current_vertex", vertex_label), edit("Vertex X", "pl2_vertex_x", vertex_x), edit("Vertex Y", "pl2_vertex_y", vertex_y), edit("Start segment width", "pl2_seg_start_w", seg_start_w), @@ -445,6 +457,13 @@ impl PropertyEditable for Polyline2D { } fn apply_geom_prop(&mut self, field: &str, value: &str) { + // Per-vertex edits target the vertex the panel is focused on. + let n = self.vertices.len(); + let vi = if n == 0 { + 0 + } else { + crate::scene::view::dispatch::prop_current_vertex().min(n - 1) + }; match field { "pl2_closed" => { let closed = if value == "toggle" { @@ -472,24 +491,24 @@ impl PropertyEditable for Polyline2D { } } "pl2_vertex_x" => { - if let (Some(v), Some(vert)) = (parse_f64(value), self.vertices.first_mut()) { + if let (Some(v), Some(vert)) = (parse_f64(value), self.vertices.get_mut(vi)) { vert.location.x = v; } } "pl2_vertex_y" => { - if let (Some(v), Some(vert)) = (parse_f64(value), self.vertices.first_mut()) { + if let (Some(v), Some(vert)) = (parse_f64(value), self.vertices.get_mut(vi)) { vert.location.y = v; } } "pl2_seg_start_w" => { - if let (Some(v), Some(vert)) = (parse_f64(value), self.vertices.first_mut()) { + if let (Some(v), Some(vert)) = (parse_f64(value), self.vertices.get_mut(vi)) { if v >= 0.0 { vert.start_width = v; } } } "pl2_seg_end_w" => { - if let (Some(v), Some(vert)) = (parse_f64(value), self.vertices.first_mut()) { + if let (Some(v), Some(vert)) = (parse_f64(value), self.vertices.get_mut(vi)) { if v >= 0.0 { vert.end_width = v; } diff --git a/src/scene/model/object.rs b/src/scene/model/object.rs index ad420e76..fcf05aa4 100644 --- a/src/scene/model/object.rs +++ b/src/scene/model/object.rs @@ -29,6 +29,9 @@ pub enum PropValue { LinetypeChoice(String), /// Boolean flag — rendered as a toggle button (e.g. Invisible). BoolToggle { field: &'static str, value: bool }, + /// A 0-based index navigated with ◀ / ▶ buttons (e.g. a polyline's Current + /// Vertex). `display` is the label shown between the arrows (e.g. "2 / 7"). + Stepper { field: &'static str, display: String }, /// Hatch pattern name — rendered as a combo_box from the catalog. HatchPatternChoice(String), /// Block attribute value keyed by its (dynamic, runtime) tag — rendered as diff --git a/src/scene/selection.rs b/src/scene/selection.rs index b5c3b673..635e91d4 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -299,6 +299,7 @@ impl Scene { PropValue::HatchPatternChoice(s) => s, PropValue::BoolToggle { value, .. } => value.to_string(), PropValue::AttrText { value, .. } => value, + PropValue::Stepper { display, .. } => display, PropValue::ColorVaries | PropValue::LwVaries => return None, }) } diff --git a/src/scene/view/dispatch.rs b/src/scene/view/dispatch.rs index 98503a3e..f1f3b9ef 100644 --- a/src/scene/view/dispatch.rs +++ b/src/scene/view/dispatch.rs @@ -8,6 +8,26 @@ use crate::entities::traits::EntityTypeOps; use crate::scene::model::object::{GripDef, PropSection}; use crate::scene::cache::properties; +thread_local! { + /// Which vertex a multi-vertex entity's Properties panel is focused on + /// (Current Vertex stepper). Set by the app from its `prop_vertex` state + /// before building or editing a polyline's properties; read by the + /// polyline `properties` / `apply_geom_prop` so the X/Y and per-vertex + /// width rows target that vertex. A thread-local keeps the per-entity trait + /// signatures unchanged (mirrors the curve-tolerance override). + static PROP_CURRENT_VERTEX: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Focus the Properties panel on vertex `i` for the next properties build / edit. +pub fn set_prop_current_vertex(i: usize) { + PROP_CURRENT_VERTEX.with(|c| c.set(i)); +} + +/// The vertex the Properties panel is focused on. +pub fn prop_current_vertex() -> usize { + PROP_CURRENT_VERTEX.with(|c| c.get()) +} + pub fn grips(entity: &EntityType) -> Vec { EntityTypeOps::grips(entity) } diff --git a/src/ui/properties.rs b/src/ui/properties.rs index dcda84b5..a215c663 100644 --- a/src/ui/properties.rs +++ b/src/ui/properties.rs @@ -149,6 +149,9 @@ pub struct PropertiesPanel { /// from `color_picker_open` so the entity colour and the background colour /// pickers are independent. pub bg_color_picker_open: bool, + /// Which vertex a multi-vertex entity (polyline) is focused on — driven by + /// the Current Vertex ◀ / ▶ stepper. Reset to 0 when the selection changes. + pub prop_vertex: usize, } impl Default for PropertiesPanel { @@ -170,6 +173,7 @@ impl Default for PropertiesPanel { color_picker_open: false, color_palette_open: false, bg_color_picker_open: false, + prop_vertex: 0, } } } @@ -353,6 +357,9 @@ impl PropertiesPanel { PropValue::BoolToggle { field, value } => { col = col.push(render_bool_row(&prop.label, *field, *value)); } + PropValue::Stepper { display, .. } => { + col = col.push(render_stepper_row(&prop.label, display)); + } PropValue::EditText(val) => { col = col.push(self.render_edit_row(&prop.label, prop.field, val)); } @@ -859,6 +866,42 @@ pub fn color_picker_dropdown<'a>( // ── Standalone helpers ──────────────────────────────────────────────────── /// A boolean toggle button row (for "Invisible" etc.). +fn render_stepper_row<'a>(label: &'a str, display: &'a str) -> Element<'a, Message> { + let arrow = |glyph: &'static str, delta: i8| { + button(text(glyph).size(FONT_SZ).color(VALUE_COLOR)) + .on_press(Message::PropVertexStep(delta)) + .padding([0, 6]) + .style(|_: &Theme, status| { + let bg = match status { + button::Status::Hovered | button::Status::Pressed => HOVER_BG, + _ => VALUE_BG, + }; + button::Style { + background: Some(Background::Color(bg)), + border: Border { + color: BORDER, + width: 1.0, + radius: 2.0.into(), + }, + text_color: VALUE_COLOR, + ..Default::default() + } + }) + }; + let widget = iced::widget::row![ + arrow("◀", -1), + text(display) + .size(FONT_SZ) + .color(VALUE_COLOR) + .width(Length::Fill) + .align_x(iced::Center), + arrow("▶", 1), + ] + .spacing(4) + .align_y(iced::Center); + prop_row_widget(label, widget.into()) +} + fn render_bool_row<'a>(label: &'a str, field: &'static str, value: bool) -> Element<'a, Message> { let btn_label = if value { "Yes" } else { "No" }; let btn =