From c4fcfacb18460022dcd08908931b15ab58ef3226 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 8 Jul 2026 20:39:40 +0300 Subject: [PATCH] feat(draw): Arc Continue command; 3-Point default Arc ribbon default + icon + dropdown order set to 3-Point. New ARC_CONT: tangent-continuing arc from the last line/arc endpoint, pick end only; Ctrl flips direction. Exit anchor recorded on commit (disambiguated by last pick) so chaining continues from the correct end. Co-Authored-By: Claude Opus 4.8 --- assets/icons/arc/arc_cont.svg | 7 ++ src/app/command_driver.rs | 5 ++ src/app/commands/draw.rs | 25 ++++++ src/app/mod.rs | 5 ++ src/app/update/dynamic.rs | 20 +++++ src/app/update/mod.rs | 9 +++ src/app/update/viewport.rs | 6 +- src/command.rs | 5 ++ src/modules/draw/draw/arc.rs | 141 +++++++++++++++++++++++++++++++--- src/modules/draw/mod.rs | 2 +- 10 files changed, 212 insertions(+), 13 deletions(-) create mode 100644 assets/icons/arc/arc_cont.svg diff --git a/assets/icons/arc/arc_cont.svg b/assets/icons/arc/arc_cont.svg new file mode 100644 index 000000000..c9919dde3 --- /dev/null +++ b/assets/icons/arc/arc_cont.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 45fec88b0..acd779976 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -11,10 +11,12 @@ impl OpenCADStudio { /// lives in exactly one place. No-op when no command is active. pub(super) fn feed_command(&mut self, input: StepInput) -> Task { let i = self.active_tab; + let ctrl = self.ctrl_down; let result: Option = { let Some(cmd) = self.tabs[i].active_cmd.as_mut() else { return Task::none(); }; + cmd.set_ctrl(ctrl); match input { StepInput::Point(p) => Some(cmd.on_point(p)), StepInput::Text(s) => cmd.on_text_input(&s), @@ -284,6 +286,9 @@ impl OpenCADStudio { &path, ); } + // Record where this draw ended so ARC_CONT can continue from it + // (before `entity` is moved into commit_entity). + self.update_cont_anchor(&entity); let label = self.history_label_from_active_cmd(i, "ENTITY"); self.push_undo_snapshot(i, label); self.commit_entity(entity); diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index efddf7465..30f1d2035 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -292,6 +292,31 @@ impl OpenCADStudio { self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } + "ARC_CONT" => { + use crate::modules::draw::draw::arc::{continue_anchor, ArcContCommand}; + // Prefer the anchor recorded when the last line/arc was drawn (it + // knows the true drawing-end tangent); otherwise fall back to the + // last line/arc found in the document (e.g. after a file load). + let seed = self.cont_anchor.or_else(|| { + self.tabs[i] + .scene + .document + .entities() + .filter_map(|e| continue_anchor(e, None)) + .last() + }); + match seed { + Some((s, tangent)) => { + let new_cmd = ArcContCommand::new(s, tangent); + self.command_line.push_info(&new_cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(new_cmd)); + } + None => { + self.command_line + .push_info("ARC Continue No previous line or arc to continue."); + } + } + } "RECT" | "RECTANG" | "REC" => { use crate::modules::draw::draw::shapes::RectCommand; diff --git a/src/app/mod.rs b/src/app/mod.rs index 69ec00905..e742d3637 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -392,6 +392,10 @@ pub(super) struct OpenCADStudio { show_layout_tabs: bool, /// Last point committed by a drawing command — used as ortho/polar base. last_point: Option, + /// Endpoint + unit exit-tangent of the most recently drawn line/arc, so + /// `ARC_CONT` (Arc → Continue) can start tangentially from where drawing + /// ended. `None` once a non-line/arc entity is committed. + cont_anchor: Option<(glam::DVec3, glam::DVec3)>, /// OS window Id for the floating Layer Properties Manager (None when closed). /// OS window Id of the primary application window. main_window: Option, @@ -2150,6 +2154,7 @@ impl OpenCADStudio { clipboard_deps: ClipboardDeps::default(), shift_down: false, ctrl_down: false, + cont_anchor: None, mtext_editor: None, text_inline: None, layout_context_menu: None, diff --git a/src/app/update/dynamic.rs b/src/app/update/dynamic.rs index f6b76b849..abc157aed 100644 --- a/src/app/update/dynamic.rs +++ b/src/app/update/dynamic.rs @@ -198,6 +198,26 @@ impl OpenCADStudio { } } + /// Push the live Ctrl state into tab `i`'s active command before it builds a + /// preview/commit (arc-direction flip on `ARC_CONT`, etc.). + pub(in crate::app) fn push_ctrl_to_cmd(&mut self, i: usize) { + let ctrl = self.ctrl_down; + if let Some(c) = self.tabs[i].active_cmd.as_mut() { + c.set_ctrl(ctrl); + } + } + + /// Cache the endpoint + exit tangent of a just-committed line/arc so a + /// following `ARC_CONT` starts tangentially from where drawing ended. Set to + /// `None` for any other entity kind. Uses `last_point` (the final pick) to + /// pick the endpoint the pen actually finished on. + pub(in crate::app) fn update_cont_anchor(&mut self, entity: &acadrust::EntityType) { + let last = self + .last_point + .map(|v| glam::DVec3::new(v.x as f64, v.y as f64, v.z as f64)); + self.cont_anchor = crate::modules::draw::draw::arc::continue_anchor(entity, last); + } + pub(in crate::app) fn dyn_resolve_point(&self) -> Option { use crate::app::document::DynComponent; let i = self.active_tab; diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 47e42f885..87cc226ea 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -1751,8 +1751,17 @@ impl OpenCADStudio { } Message::SetModifiers { shift, ctrl } => { + let ctrl_changed = self.ctrl_down != ctrl; self.shift_down = shift; self.ctrl_down = ctrl; + // A live command may key its preview off Ctrl (arc-direction + // flip). Rebuild it at the current cursor so the flip shows + // without waiting for the next mouse move. + let i = self.active_tab; + if ctrl_changed && self.tabs[i].active_cmd.is_some() { + let p = self.tabs[i].last_cursor_screen; + return Task::done(Message::ViewportMove(p)); + } Task::none() } diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 5f0714d07..98b2f4aca 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -1090,9 +1090,11 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { s.screen.y += tile_b.y; } - // Give the command the current UCS before it builds its - // rubber-band preview (and, by persistence, its commit). + // Give the command the current UCS + Ctrl state before it + // builds its rubber-band preview (and, by persistence, its + // commit). self.push_ucs_to_cmd(i); + self.push_ctrl_to_cmd(i); let mut previews = if needs_structure { let mut p = self.tabs[i] .active_cmd diff --git a/src/command.rs b/src/command.rs index b2c6cd001..27629f385 100644 --- a/src/command.rs +++ b/src/command.rs @@ -552,6 +552,11 @@ pub trait CadCommand: Send { /// ignore it. Called before each point / preview dispatch. fn set_ucs(&mut self, _ucs: glam::Mat4) {} + /// Push the live Ctrl-key state into the command before each preview/commit + /// dispatch. Commands that offer a Ctrl toggle (e.g. arc-direction flip on + /// `ARC_CONT`) store it; most commands ignore it. Default no-op. + fn set_ctrl(&mut self, _ctrl: bool) {} + /// Called when the user left-clicks in the viewport (point pick). fn on_point(&mut self, pt: DVec3) -> CmdResult; diff --git a/src/modules/draw/draw/arc.rs b/src/modules/draw/draw/arc.rs index d4fa47795..fa4a4c86f 100644 --- a/src/modules/draw/draw/arc.rs +++ b/src/modules/draw/draw/arc.rs @@ -1,16 +1,17 @@ // Arc tool — ribbon dropdown + all OpenCADStudio arc creation methods. // -// Methods: -// ARC — Center, Start, End (CSE — default) -// ARC_3P — 3-Point +// Methods (dropdown order): +// ARC_3P — 3-Point (default) // ARC_SCE — Start, Center, End // ARC_SCA — Start, Center, Angle // ARC_SCL — Start, Center, Length of chord -// ARC_SEA — Start, End, Angle (sagitta-based pick) -// ARC_SER — Start, End, Radius (radius defined by dist cursor→start) -// ARC_SED — Start, End, Direction (tangent at start) +// ARC_SEA — Start, End, Angle (sagitta-based pick) +// ARC_SED — Start, End, Direction (tangent at start) +// ARC_SER — Start, End, Radius (radius defined by dist cursor→start) +// ARC — Center, Start, End (CSE) // ARC_CSA — Center, Start, Angle // ARC_CSL — Center, Start, Length of chord +// ARC_CONT— Continue (tangent to previous line/arc; pick end only) use acadrust::types::Vector3; use acadrust::{Arc as CadArc, EntityType}; @@ -43,26 +44,29 @@ const ICON_CSA: IconKind = IconKind::Svg(include_bytes!("../../../../assets/icons/arc/arc_csa.svg")); const ICON_CSL: IconKind = IconKind::Svg(include_bytes!("../../../../assets/icons/arc/arc_csl.svg")); +const ICON_CONT: IconKind = + IconKind::Svg(include_bytes!("../../../../assets/icons/arc/arc_cont.svg")); // ── Dropdown metadata ────────────────────────────────────────────────────── pub const DROPDOWN_ID: &str = "ARC"; pub const DROPDOWN_ITEMS: &[(&str, &str, IconKind)] = &[ - ("ARC", "Center, Start, End", ICON_CSE), ("ARC_3P", "3-Point", ICON_3P), ("ARC_SCE", "Start, Center, End", ICON_SCE), ("ARC_SCA", "Start, Center, Angle", ICON_SCA), ("ARC_SCL", "Start, Center, Length", ICON_SCL), ("ARC_SEA", "Start, End, Angle", ICON_SEA), - ("ARC_SER", "Start, End, Radius", ICON_SER), ("ARC_SED", "Start, End, Direction", ICON_SED), + ("ARC_SER", "Start, End, Radius", ICON_SER), + ("ARC", "Center, Start, End", ICON_CSE), ("ARC_CSA", "Center, Start, Angle", ICON_CSA), ("ARC_CSL", "Center, Start, Length", ICON_CSL), + ("ARC_CONT", "Continue", ICON_CONT), ]; -/// Default icon — falls back to CSE before first use. -pub const ICON: IconKind = ICON_CSE; +/// Default icon — falls back to 3-Point before first use. +pub const ICON: IconKind = ICON_3P; // ── Shared math helpers ──────────────────────────────────────────────────── @@ -209,6 +213,68 @@ fn arc_from_direction(s: DVec3, e: DVec3, dir_pt: DVec3) -> Option<(DVec3, f64)> Some((center, center.distance(s))) } +/// Tangent-continuing arc: starts at `s` leaving in unit direction `t` (forward, +/// G1-continuous with the previous entity) and passes through `e`. Returns +/// `(center, radius, start_angle, end_angle)` with the CCW sweep oriented so the +/// drawn arc actually leaves `s` along `+t` (not the reflex complement). +/// +/// `arc_from_direction` treats the tangent as an undirected line (±t give the +/// same circle), so on its own the CCW-only renderer would draw the wrong arc +/// whenever `e` lies to the right of `+t`. We fix the sweep direction here by +/// checking the circle's CCW travel direction at `s` and swapping the angles +/// when it opposes `+t`. `flip` (Ctrl) selects the complementary arc — the other +/// way around the same circle. +fn arc_continue(s: DVec3, t: DVec3, e: DVec3, flip: bool) -> Option<(DVec3, f64, f64, f64)> { + let (center, radius) = arc_from_direction(s, e, s + t)?; + // CCW travel direction on this circle at s = (s - center) rotated +90°. + let rs = s - center; + let travel_s = DVec3::new(-rs.y, rs.x, 0.0); + let forward = travel_s.dot(t) >= 0.0; + // Draw CCW s→e when the CCW-from-s arc leaves along +t; else CCW e→s. `flip` + // inverts the choice to draw the complementary arc. + let (sa, ea) = if forward ^ flip { + (angle_xy(center, s), angle_xy(center, e)) + } else { + (angle_xy(center, e), angle_xy(center, s)) + }; + Some((center, radius, sa, ea)) +} + +/// Continuation anchor for `ARC_CONT`: the point where drawing a line/arc ended +/// plus the unit tangent leaving it, used to start a tangent-continuing arc. +/// `last` (the final pick point) disambiguates which endpoint the drawing ended +/// on — essential because a committed arc stores geometric CCW start/end angles, +/// not the direction the pen travelled. Returns `None` for other entity kinds. +pub fn continue_anchor(entity: &EntityType, last: Option) -> Option<(DVec3, DVec3)> { + let nearer_first = |a: DVec3, b: DVec3| match last { + Some(p) => p.distance(a) <= p.distance(b), + None => true, // default to the entity's stored end + }; + match entity { + EntityType::Line(l) => { + let a = DVec3::new(l.start.x, l.start.y, l.start.z); + let b = DVec3::new(l.end.x, l.end.y, l.end.z); + let (p_end, p_start) = if nearer_first(b, a) { (b, a) } else { (a, b) }; + let t = (p_end - p_start).normalize_or_zero(); + (t.length_squared() > 1e-12).then_some((p_end, t)) + } + EntityType::Arc(a) => { + let sp_v = a.start_point(); + let ep_v = a.end_point(); + let sp = DVec3::new(sp_v.x, sp_v.y, sp_v.z); + let ep = DVec3::new(ep_v.x, ep_v.y, ep_v.z); + if nearer_first(ep, sp) { + // Ended at end_angle: outward tangent = CCW travel there. + Some((ep, DVec3::new(-a.end_angle.sin(), a.end_angle.cos(), 0.0))) + } else { + // Ended at start_angle: outward tangent = CW travel there. + Some((sp, DVec3::new(a.start_angle.sin(), -a.start_angle.cos(), 0.0))) + } + } + _ => None, + } +} + /// Compute end_angle from a chord-length pick (SCL / CSL semantics). /// `chord_len` is clamped to [0, 2r]. fn end_angle_from_chord_len(start_angle: f64, chord: f64, r: f64) -> f64 { @@ -1258,6 +1324,60 @@ impl CadCommand for ArcCSLCommand { } +// ── Command 11: Continue (ARC_CONT) ────────────────────────────────────── +// Tangent-continuing arc from the previous line/arc endpoint. The start point +// and start tangent are seeded at dispatch (from `continue_anchor`); the user +// picks only the end point, so the arc joins the previous entity smoothly. + +pub struct ArcContCommand { + s: DVec3, + tangent: DVec3, + /// Live Ctrl state (set via `set_ctrl`): flips the arc to the other way. + ctrl: bool, +} + +impl ArcContCommand { + pub fn new(s: DVec3, tangent: DVec3) -> Self { + Self { + s, + tangent, + ctrl: false, + } + } +} + +impl CadCommand for ArcContCommand { + fn name(&self) -> &'static str { + "ARC_CONT" + } + fn prompt(&self) -> String { + "ARC Continue Specify end point [Ctrl = flip direction]:".into() + } + fn set_ctrl(&mut self, ctrl: bool) { + self.ctrl = ctrl; + } + fn on_point(&mut self, pt: DVec3) -> CmdResult { + match arc_continue(self.s, self.tangent, pt, self.ctrl) { + Some((center, radius, sa, ea)) => { + CmdResult::CommitAndExit(make_arc(center, radius, sa, ea)) + } + None => CmdResult::Cancel, + } + } + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel + } + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } + fn on_mouse_move(&mut self, pt: DVec3) -> Option { + match arc_continue(self.s, self.tangent, pt, self.ctrl) { + Some((center, radius, sa, ea)) => Some(arc_preview(center, radius, sa, ea)), + None => Some(line_wire(self.s, pt)), + } + } +} + // ── Autocomplete registry ───────────────────────────────── inventory::submit!(crate::command::CommandRegistration { names: &["ARC_3P"] }); // Arc3PCommand inventory::submit!(crate::command::CommandRegistration { names: &["ARC_CSA"] }); // ArcCSACommand @@ -1269,3 +1389,4 @@ inventory::submit!(crate::command::CommandRegistration { names: &["ARC_SCL"] }); inventory::submit!(crate::command::CommandRegistration { names: &["ARC_SEA"] }); // ArcSEACommand inventory::submit!(crate::command::CommandRegistration { names: &["ARC_SED"] }); // ArcSEDCommand inventory::submit!(crate::command::CommandRegistration { names: &["ARC_SER"] }); // ArcSERCommand +inventory::submit!(crate::command::CommandRegistration { names: &["ARC_CONT"] }); // ArcContCommand diff --git a/src/modules/draw/mod.rs b/src/modules/draw/mod.rs index ac419385a..4d658ff6d 100644 --- a/src/modules/draw/mod.rs +++ b/src/modules/draw/mod.rs @@ -62,7 +62,7 @@ impl CadModule for DrawModule { label: "Arc", icon: arc::ICON, items: arc::DROPDOWN_ITEMS.to_vec(), - default: "ARC", + default: "ARC_3P", }, RibbonItem::Dropdown { id: shapes::DROPDOWN_ID,