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 =