From e31dbcb5c41ad510b1c3365e3f00ef05f349bc73 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Mon, 15 Jun 2026 18:46:34 +0300 Subject: [PATCH] feat(dynamic-input): per-step specs for rectangle, polygon and ellipse Extend the DynSpec coverage and add the guides those commands need: - Rectangle: two-corner enters width/height as unsigned magnitudes (the sign follows the cursor side, like the angle entry), drawn with the rectangle as the guide; rotated rectangle's height is a single distance measured square to the base edge (dimension line offset off the edge with extension lines); centre rectangle shows half-width/half-height. - Polygon: inscribed/circumscribed vertex step shows radius + rotation. - Ellipse: minor-axis step (all three modes) measures the half-length perpendicular to the major axis and draws that semi-axis from the centre; angle steps anchor their arc at the centre. Adds DynGuide::Perp / PerpDim (perpendicular measure to a reference line) and DynSpec::ref_point, plus Width/Height role handling (unsigned display, cursor-signed commit) and diameter value scaling. Co-Authored-By: Claude Opus 4.8 --- src/app/document.rs | 7 +++ src/app/update.rs | 52 +++++++++++++++++--- src/app/view.rs | 8 +++- src/command/mod.rs | 17 ++++++- src/modules/home/draw/circle.rs | 2 + src/modules/home/draw/ellipse.rs | 81 ++++++++++++++++++++++++++++++++ src/modules/home/draw/shapes.rs | 80 +++++++++++++++++++++++++++++++ src/ui/overlay.rs | 59 +++++++++++++++++++++++ 8 files changed, 296 insertions(+), 10 deletions(-) diff --git a/src/app/document.rs b/src/app/document.rs index 13b6bda16..7fd933d5a 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -132,6 +132,11 @@ pub(super) struct DocumentTab { /// World-space anchor the current step's values are measured from. `None` /// falls back to `App::last_point`. pub(super) dyn_anchor: Option, + /// Far end of a reference line through `dyn_anchor` (for the `Perp` guide: + /// the base edge / major axis the offset is measured square to). + pub(super) dyn_ref: Option, + /// `dyn_ref` projected to viewport pixels. + pub(super) dyn_ref_screen: Option, /// Index of the field that TAB has focused (the one keystrokes edit). pub(super) dyn_active: usize, pub(super) history: HistoryState, @@ -195,6 +200,8 @@ impl DocumentTab { dyn_fields: Vec::new(), dyn_guide: crate::command::DynGuide::Polar, dyn_anchor: None, + dyn_ref: None, + dyn_ref_screen: None, dyn_active: 0, history: HistoryState::default(), active_layer: "0".to_string(), diff --git a/src/app/update.rs b/src/app/update.rs index b20d647d3..371b9db21 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -2545,14 +2545,16 @@ impl OpenCADStudio { // Project the step anchor (an explicit `dyn_anchor` or the // last point) so the dynamic-input overlay can place its // guide geometry and labels. - let anchor = self.tabs[i].dyn_anchor.or(self.last_point); - self.tabs[i].last_point_screen = anchor.map(|bp| { + let project = |bp: glam::Vec3| { let ndc = view_proj.project_point3(bp); iced::Point::new( (ndc.x + 1.0) * 0.5 * bounds.width, (1.0 - ndc.y) * 0.5 * bounds.height, ) - }); + }; + let anchor = self.tabs[i].dyn_anchor.or(self.last_point); + self.tabs[i].last_point_screen = anchor.map(project); + self.tabs[i].dyn_ref_screen = self.tabs[i].dyn_ref.map(project); // Entity-pick previews (TRIM/EXTEND/FILLET…) compare the // cursor against WCS document entities and return WCS wires. @@ -7590,6 +7592,7 @@ impl OpenCADStudio { _ => crate::command::DynGuide::None, }; self.tabs[i].dyn_anchor = self.last_point; + self.tabs[i].dyn_ref = None; } /// Apply an explicit per-step [`DynSpec`](crate::command::DynSpec): rebuild @@ -7611,6 +7614,7 @@ impl OpenCADStudio { crate::command::DynAnchor::LastPoint => self.last_point, crate::command::DynAnchor::Point(p) => Some(p), }; + self.tabs[i].dyn_ref = spec.ref_point; } /// Track cursor dwell over a selected entity's grip. Sets @@ -7783,15 +7787,29 @@ impl OpenCADStudio { .or(self.last_point) .unwrap_or(glam::Vec3::ZERO); // Buffer value parsed as f32 (de-scaled by the role so a typed diameter - // becomes a radius), or the supplied geometric live value. + // becomes a radius), or the supplied geometric live value. Width/Height + // are shown unsigned, so a typed value takes the sign of the cursor's + // delta on that axis (`live` is the signed delta in the cartesian arms). let val = |idx: usize, live: f32| -> f32 { - fields[idx] + match fields[idx] .buffer .as_ref() .map(|s| s.trim().replace(',', ".")) .and_then(|s| crate::app::expr_eval::eval_number(&s).map(|v| v as f32)) .map(|v| v / fields[idx].role.value_scale()) - .unwrap_or(live) + { + Some(v) => { + if matches!( + fields[idx].role, + crate::command::DynRole::Width | crate::command::DynRole::Height + ) { + v.abs().copysign(live) + } else { + v + } + } + None => live, + } }; let dx = w.x - base.x; let dy = w.y - base.y; @@ -7812,6 +7830,28 @@ impl OpenCADStudio { } }; let comps: Vec = fields.iter().map(|f| f.component).collect(); + // Perpendicular offset: a single distance measured square to the + // reference line (anchor → dyn_ref). The committed point lies on the + // perpendicular through the anchor at that offset; the command projects + // it. Untyped tracks the cursor's signed offset; typed takes the + // cursor's side. + if let (Some(ref_pt), [DynComponent::Distance]) = + (self.tabs[i].dyn_ref, comps.as_slice()) + { + let axis = (ref_pt - base).normalize_or_zero(); + let perp = glam::Vec3::new(-axis.y, axis.x, 0.0); + let signed = (w - base).dot(perp); + let typed = fields[0] + .buffer + .as_ref() + .map(|s| s.trim().replace(',', ".")) + .and_then(|s| crate::app::expr_eval::eval_number(&s).map(|v| v as f32)); + let h = match typed { + Some(v) => v.abs().copysign(signed), + None => signed, + }; + return Some(base + perp * h); + } // DYN-on defaults to RELATIVE coordinates when a base point is set // (see #26 / #35). The live cartesian fallback is the cursor // position relative to base; typed values are relative deltas. diff --git a/src/app/view.rs b/src/app/view.rs index 3aaf566e6..b9905727f 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -763,6 +763,7 @@ impl OpenCADStudio { Some(overlay::dynamic_input_overlay( tab.last_cursor_screen, tab.last_point_screen, + tab.dyn_ref_screen, tab.dyn_guide, boxes, prompt, @@ -4084,9 +4085,12 @@ fn dyn_component_value( // in `dyn_resolve_point` so the live preview and the committed // coordinate use the same frame. See #35. let has_base = base.is_some(); + // Width / Height read as unsigned magnitudes (the sign is taken from the + // cursor side on commit), matching the rectangle's two-edge entry. + let wh = matches!(f.role, crate::command::DynRole::Width | crate::command::DynRole::Height); match f.component { - DynComponent::X if has_base => format!("{:.4}", dx), - DynComponent::Y if has_base => format!("{:.4}", dy), + DynComponent::X if has_base => format!("{:.4}", if wh { dx.abs() } else { dx }), + DynComponent::Y if has_base => format!("{:.4}", if wh { dy.abs() } else { dy }), DynComponent::Z if has_base => "0.0000".to_string(), DynComponent::X => format!("{:.4}", w.x), DynComponent::Y => format!("{:.4}", w.y), diff --git a/src/command/mod.rs b/src/command/mod.rs index cd468f095..b432834e6 100644 --- a/src/command/mod.rs +++ b/src/command/mod.rs @@ -278,7 +278,8 @@ pub enum DynRole { Height, /// Typed-only scale factor. Factor, - /// Typed-only integer count. + /// Typed-only integer count. Reserved for upcoming command migrations. + #[allow(dead_code)] Count, } @@ -321,12 +322,21 @@ pub enum DynGuide { Radius, /// The two rectangle sides (width × height) from the anchor corner. RectSides, + /// A line from the anchor, perpendicular to the reference line (anchor → + /// `DynSpec::ref_point`), reaching the cursor's perpendicular offset — the + /// measured semi-axis (ellipse minor). The value is that offset. + Perp, + /// Like `Perp` but drawn as a dimension: the measured segment is offset off + /// the edge with extension lines back to its endpoints (rectangle height). + PerpDim, } /// Where a step's values are measured from. #[derive(Clone, Copy, PartialEq, Debug)] pub enum DynAnchor { - /// The previous committed point (`App::last_point`). + /// The previous committed point (`App::last_point`). Reserved — current + /// specs pass the anchor explicitly via `Point`. + #[allow(dead_code)] LastPoint, /// An explicit world point. Point(Vec3), @@ -352,6 +362,9 @@ pub struct DynSpec { pub anchor: DynAnchor, pub fields: Vec, pub guide: DynGuide, + /// Far end of a reference line through `anchor` (only used by + /// [`DynGuide::Perp`]); `None` otherwise. + pub ref_point: Option, } // ── Trait ───────────────────────────────────────────────────────────────── diff --git a/src/modules/home/draw/circle.rs b/src/modules/home/draw/circle.rs index 18a7e9c93..91d44684e 100644 --- a/src/modules/home/draw/circle.rs +++ b/src/modules/home/draw/circle.rs @@ -201,6 +201,7 @@ impl CadCommand for CircleCommand { anchor: DynAnchor::Point(c), fields: vec![DynFieldSpec::new(DynRole::Radius)], guide: DynGuide::Radius, + ref_point: None, }), } } @@ -288,6 +289,7 @@ impl CadCommand for CircleCDCommand { anchor: DynAnchor::Point(c), fields: vec![DynFieldSpec::new(DynRole::Diameter)], guide: DynGuide::Radius, + ref_point: None, }), } } diff --git a/src/modules/home/draw/ellipse.rs b/src/modules/home/draw/ellipse.rs index 64469278b..76520b300 100644 --- a/src/modules/home/draw/ellipse.rs +++ b/src/modules/home/draw/ellipse.rs @@ -182,6 +182,31 @@ impl CadCommand for EllipseCommand { _ => None, } } + + fn dyn_spec(&self) -> Option { + use crate::command::{DynAnchor, DynFieldSpec, DynGuide, DynRole, DynSpec}; + match &self.step { + // Center + major axis endpoint: ordinary point picks (legacy polar + // anchored at the previous point). + CtrStep::Center | CtrStep::MajorAxis { .. } => None, + // Minor axis: half-length measured square to the major axis. Show + // the perpendicular drop from the cursor onto the major axis. + CtrStep::MinorRatio { center, major } => Some(DynSpec { + anchor: DynAnchor::Point(*center), + fields: vec![DynFieldSpec::new(DynRole::Distance)], + guide: DynGuide::Perp, + ref_point: Some(*center + *major), + }), + } + } + + fn dyn_live_value(&self, cursor: Vec3) -> Option { + if let CtrStep::MinorRatio { center, major } = &self.step { + Some((minor_ratio(*center, *major, cursor) * major.length()) as f64) + } else { + None + } + } } // ── 2. Axis, End mode ───────────────────────────────────────────────────── @@ -270,6 +295,30 @@ impl CadCommand for EllipseAxisCommand { } } } + + fn dyn_spec(&self) -> Option { + use crate::command::{DynAnchor, DynFieldSpec, DynGuide, DynRole, DynSpec}; + match &self.step { + // Endpoints define the full major axis — legacy polar (anchored at + // the previous point) is right. + AxisStep::Pt1 | AxisStep::Pt2 { .. } => None, + // Minor half-length, square to the major axis. + AxisStep::MinorRatio { center, major } => Some(DynSpec { + anchor: DynAnchor::Point(*center), + fields: vec![DynFieldSpec::new(DynRole::Distance)], + guide: DynGuide::Perp, + ref_point: Some(*center + *major), + }), + } + } + + fn dyn_live_value(&self, cursor: Vec3) -> Option { + if let AxisStep::MinorRatio { center, major } = &self.step { + Some((minor_ratio(*center, *major, cursor) * major.length()) as f64) + } else { + None + } + } } // ── 3. Ellipse Arc mode ─────────────────────────────────────────────────── @@ -487,6 +536,38 @@ impl CadCommand for EllipseArcCommand { _ => None, } } + + fn dyn_spec(&self) -> Option { + use crate::command::{DynAnchor, DynFieldSpec, DynGuide, DynRole, DynSpec}; + match &self.step { + ArcStep::Center | ArcStep::MajorAxis { .. } => None, + // Minor half-length, square to the major axis. + ArcStep::MinorRatio { center, major } => Some(DynSpec { + anchor: DynAnchor::Point(*center), + fields: vec![DynFieldSpec::new(DynRole::Distance)], + guide: DynGuide::Perp, + ref_point: Some(*center + *major), + }), + // Start / end sweep angles measured at the centre (the last point + // is the previous pick, so anchor the angle arc at the centre). + ArcStep::StartAngle { center, .. } | ArcStep::EndAngle { center, .. } => { + Some(DynSpec { + anchor: DynAnchor::Point(*center), + fields: vec![DynFieldSpec::new(DynRole::Angle)], + guide: DynGuide::Polar, + ref_point: None, + }) + } + } + } + + fn dyn_live_value(&self, cursor: Vec3) -> Option { + if let ArcStep::MinorRatio { center, major } = &self.step { + Some((minor_ratio(*center, *major, cursor) * major.length()) as f64) + } else { + None + } + } } // ── Internal helpers ────────────────────────────────────────────────────── diff --git a/src/modules/home/draw/shapes.rs b/src/modules/home/draw/shapes.rs index c38b999ae..4b8b71948 100644 --- a/src/modules/home/draw/shapes.rs +++ b/src/modules/home/draw/shapes.rs @@ -168,6 +168,21 @@ impl CadCommand for RectCommand { [a.x, pt.y, a.z], ])) } + fn dyn_spec(&self) -> Option { + use crate::command::{DynAnchor, DynFieldSpec, DynGuide, DynRole, DynSpec}; + // Opposite corner: enter width and height (signed deltas from the first + // corner), with the rectangle drawn as the guide. First corner is a + // normal point pick. + self.a.map(|a| DynSpec { + anchor: DynAnchor::Point(a), + fields: vec![ + DynFieldSpec::new(DynRole::Width), + DynFieldSpec::new(DynRole::Height), + ], + guide: DynGuide::RectSides, + ref_point: None, + }) + } } // ── Command: Rectangle — Rotated (RECT_ROT) ────────────────────────────── @@ -256,6 +271,29 @@ impl CadCommand for RectRotCommand { _ => None, } } + + fn dyn_spec(&self) -> Option { + use crate::command::{DynAnchor, DynFieldSpec, DynGuide, DynRole, DynSpec}; + // Step 0: corner A (point). Step 1: adjacent corner — the base edge, + // needs direction + length (legacy polar). Step 2: height — measured + // square to the fixed base edge A→B, so show the perpendicular drop + // and take the perpendicular distance (no angle). + (self.step == 2).then(|| DynSpec { + anchor: DynAnchor::Point(self.b), + fields: vec![DynFieldSpec::new(DynRole::Distance)], + guide: DynGuide::PerpDim, + ref_point: Some(self.a), + }) + } + + fn dyn_live_value(&self, cursor: Vec3) -> Option { + // Live height = perpendicular distance from the cursor to the base edge. + (self.step == 2).then(|| { + let dir = (self.b - self.a).normalize_or_zero(); + let perp = Vec3::new(-dir.y, dir.x, 0.0); + (cursor - self.b).dot(perp).abs() as f64 + }) + } } // ── Command: Rectangle — Center (RECT_CEN) ─────────────────────────────── @@ -320,6 +358,20 @@ impl CadCommand for RectCenCommand { [c.x - hw, c.y + hh, c.z], ])) } + fn dyn_spec(&self) -> Option { + use crate::command::{DynAnchor, DynFieldSpec, DynGuide, DynRole, DynSpec}; + // Corner from the centre gives the half-width / half-height; show them + // on dotted axis legs out of the centre. + self.center.map(|c| DynSpec { + anchor: DynAnchor::Point(c), + fields: vec![ + DynFieldSpec::new(DynRole::Width), + DynFieldSpec::new(DynRole::Height), + ], + guide: DynGuide::AxisDelta, + ref_point: None, + }) + } } // ── Command: Polygon — Inscribed (POLY) ────────────────────────────────── @@ -359,6 +411,20 @@ impl CadCommand for PolyCommand { } } + fn dyn_spec(&self) -> Option { + use crate::command::{DynAnchor, DynFieldSpec, DynGuide, DynRole, DynSpec}; + // Vertex on the circle: radius from the centre + rotation angle. + (self.step == 2).then(|| DynSpec { + anchor: DynAnchor::Point(self.center), + fields: vec![ + DynFieldSpec::new(DynRole::Radius), + DynFieldSpec::new(DynRole::Angle), + ], + guide: DynGuide::Polar, + ref_point: None, + }) + } + fn on_text_input(&mut self, text: &str) -> Option { if let Ok(n) = text.trim().parse::() { if (3..=1024).contains(&n) { @@ -469,6 +535,20 @@ impl CadCommand for PolyCCommand { Some(CmdResult::NeedPoint) } + fn dyn_spec(&self) -> Option { + use crate::command::{DynAnchor, DynFieldSpec, DynGuide, DynRole, DynSpec}; + // Edge-midpoint distance (apothem) from the centre + rotation. + (self.step == 2).then(|| DynSpec { + anchor: DynAnchor::Point(self.center), + fields: vec![ + DynFieldSpec::new(DynRole::Radius), + DynFieldSpec::new(DynRole::Angle), + ], + guide: DynGuide::Polar, + ref_point: None, + }) + } + fn prompt(&self) -> String { match self.step { 0 => format!("POLYGON C Enter number of sides <{}>:", self.sides), diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index b54cc8709..d6f3d7c8e 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -1217,6 +1217,7 @@ pub struct DynBox { pub fn dynamic_input_overlay<'a>( cursor_screen: Point, base_screen: Option, + ref_screen: Option, guide: DynGuide, boxes: Vec, prompt: String, @@ -1224,6 +1225,7 @@ pub fn dynamic_input_overlay<'a>( canvas(DynInputCanvas { cursor_screen, base_screen, + ref_screen, guide, boxes, prompt, @@ -1238,6 +1240,8 @@ struct DynInputCanvas { /// Step anchor in viewport pixels (projected `dyn_anchor`). Guided layouts /// (polar / radius / axis-delta) need it; `None` falls back to a cursor row. base_screen: Option, + /// Far end of the reference line (projected `dyn_ref`) — for `Perp`. + ref_screen: Option, guide: DynGuide, boxes: Vec, /// The active command's current prompt, drawn just above the boxes. @@ -1351,6 +1355,22 @@ impl DynInputCanvas { } let corner = Point { x: cursor.x, y: base.y }; // axis-delta elbow + // Perp / PerpDim: perpendicular direction to the reference line, the + // measured endpoint along it (`end`), and an offset dimension segment + // (`off_base`→`off_end`) drawn clear of the edge for PerpDim. + let perp_info = self.ref_screen.map(|r| { + let (ax, ay) = (r.x - base.x, r.y - base.y); + let al = (ax * ax + ay * ay).sqrt().max(1.0); + let (ux, uy) = (ax / al, ay / al); // axis unit (base → ref) + let (px, py) = (-uy, ux); // perpendicular unit + let signed = (cursor.x - base.x) * px + (cursor.y - base.y) * py; + let end = Point { x: base.x + px * signed, y: base.y + py * signed }; + const OFF: f32 = 16.0; // dimension offset, away from the reference + let off_base = Point { x: base.x - ux * OFF, y: base.y - uy * OFF }; + let off_end = Point { x: end.x - ux * OFF, y: end.y - uy * OFF }; + (end, off_base, off_end) + }); + // ── Guide geometry ── match self.guide { DynGuide::Polar => { @@ -1383,6 +1403,34 @@ impl DynInputCanvas { }); frame.stroke(&line, Self::dotted()); } + DynGuide::Perp => { + if let Some((end, _, _)) = perp_info { + // The measured semi-axis: anchor → perpendicular endpoint. + let line = canvas::Path::new(|p| { + p.move_to(base); + p.line_to(end); + }); + frame.stroke(&line, Self::dotted()); + } + } + DynGuide::PerpDim => { + if let Some((end, ob, oe)) = perp_info { + // Dimension segment offset off the edge, with extension + // lines back to the two measured corners. + let dim = canvas::Path::new(|p| { + p.move_to(ob); + p.line_to(oe); + }); + frame.stroke(&dim, Self::dotted()); + let ext = canvas::Path::new(|p| { + p.move_to(base); + p.line_to(ob); + p.move_to(end); + p.line_to(oe); + }); + frame.stroke(&ext, Self::dotted()); + } + } DynGuide::AxisDelta | DynGuide::RectSides => { // Dotted legs from the anchor along its axes to the cursor. let legs = canvas::Path::new(|p| { @@ -1422,6 +1470,17 @@ impl DynInputCanvas { x: corner.x + 18.0, y: (base.y + cursor.y) * 0.5, }, + // Perpendicular measure: on the measured segment / dim line. + _ if matches!(self.guide, DynGuide::Perp | DynGuide::PerpDim) + && perp_info.is_some() => + { + let (end, ob, oe) = perp_info.unwrap(); + if self.guide == DynGuide::PerpDim { + Point { x: (ob.x + oe.x) * 0.5 + 8.0, y: (ob.y + oe.y) * 0.5 } + } else { + Point { x: (base.x + end.x) * 0.5 + 8.0, y: (base.y + end.y) * 0.5 } + } + } // Distance / Radius / Diameter and anything else ride the line. _ => Point { x: base.x + dx * len * 0.5 + nx * 16.0,