diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index e91488f3e..cad5c068e 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -1277,25 +1277,58 @@ impl OpenCADStudio { self.restore_pre_cmd_tangent(); } CmdResult::PeditOp { handle, op } => { - use crate::modules::draw::modify::pedit::apply_pedit; - let changed = self.tabs[i] - .scene - .document - .get_entity_mut(handle) - .map(|e| apply_pedit(e, &op)) - .unwrap_or(false); - if changed { - self.push_undo_snapshot(i, "PEDIT"); - self.tabs[i].dirty = true; - self.command_line.push_output("PEDIT: applied."); - self.refresh_properties(); - } else { - self.command_line - .push_error("PEDIT: operation not applicable to this entity."); + use crate::modules::draw::modify::pedit::{ + apply_pedit, convert_to_polyline, PeditOp, + }; + match &op { + // The convert replaces the entity (new handle). + PeditOp::ConvertToPolyline => { + let converted = self.tabs[i] + .scene + .document + .get_entity(handle) + .and_then(convert_to_polyline); + match converted { + Some(pl) => { + return self.apply_cmd_result(CmdResult::ReplaceEntity( + handle, + vec![pl], + )); + } + None => self + .command_line + .push_error("PEDIT: cannot convert this entity."), + } + } + _ => { + // Snapshot BEFORE the mutation — an after-the-fact + // snapshot records the changed state and undo no-ops. + self.push_undo_snapshot(i, "PEDIT"); + let changed = self.tabs[i] + .scene + .document + .get_entity_mut(handle) + .map(|e| apply_pedit(e, &op)) + .unwrap_or(false); + if changed { + self.tabs[i].dirty = true; + // Repaint immediately (wide-band fill included). + self.tabs[i].scene.mark_entity_dirty(handle); + self.tabs[i].scene.refresh_fill_model(handle); + self.tabs[i].scene.bump_geometry_no_blocks(); + self.command_line.push_output("PEDIT: applied."); + self.refresh_properties(); + } else { + let _ = self.tabs[i].history.undo_stack.pop(); + self.command_line + .push_error("PEDIT: operation not applicable to this entity."); + } + } + } + let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); + if let Some(p) = prompt { + self.command_line.push_info(&p); } - // Keep command active — user may apply more ops - self.command_line - .push_info("PEDIT Enter option [C=Close O=Open W=Width X=Exit]:"); } CmdResult::JoinEntities(handles) => { use crate::modules::draw::modify::join::join_entities; diff --git a/src/app/commands/inquiry.rs b/src/app/commands/inquiry.rs index cb72e8b23..bc1a823fc 100644 --- a/src/app/commands/inquiry.rs +++ b/src/app/commands/inquiry.rs @@ -223,6 +223,15 @@ impl OpenCADStudio { // ── Break / Join ───────────────────────────────────────────────── "JOIN" => { use crate::modules::draw::modify::join::JoinCommand; + // Pickfirst: with objects already selected, join them right + // away instead of asking for a selection again. + let selected: Vec = + self.tabs[i].scene.selected.iter().copied().collect(); + if selected.len() >= 2 { + let task = + self.apply_cmd_result(crate::command::CmdResult::JoinEntities(selected)); + return Some(task); + } let cmd = JoinCommand::new(); self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); @@ -243,8 +252,40 @@ impl OpenCADStudio { } "PEDIT" => { - use crate::modules::draw::modify::pedit::PeditCommand; - let cmd_obj = PeditCommand::new(); + use crate::modules::draw::modify::pedit::{PeditCommand, PeditTarget}; + let info = self.tabs[i] + .scene + .document + .entities() + .filter_map(|e| { + let h = e.common().handle.value(); + match e { + acadrust::EntityType::LwPolyline(_) + | acadrust::EntityType::Polyline2D(_) => Some(( + h, + PeditTarget { + is_poly: true, + convertible: false, + }, + )), + acadrust::EntityType::Line(_) | acadrust::EntityType::Arc(_) => { + Some(( + h, + PeditTarget { + is_poly: false, + convertible: true, + }, + )) + } + _ => None, + } + }) + .collect(); + // Pickfirst: an already-selected polyline (or line/arc, via + // the convert prompt) skips the select step. + let preselected: Vec = + self.tabs[i].scene.selected.iter().copied().collect(); + let cmd_obj = PeditCommand::new(info).with_preselection(&preselected); self.command_line.push_info(&cmd_obj.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd_obj)); } diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 80ae451d9..ac6443e1c 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -935,6 +935,33 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.command_line.push_info(&format!("{label}:")); return self.focus_cmd_input(); } + // Break at vertex replaces the entity with the split pieces — + // structural, so it can't ride the in-place apply below. + if matches!(item.action, GripMenuAction::BreakVertex) { + let pieces = match self.tabs[i].scene.document.get_entity(popup.handle) { + Some(acadrust::EntityType::LwPolyline(p)) => { + crate::entities::lwpolyline::break_at_vertex(p, popup.grip_id) + } + _ => None, + }; + match pieces { + Some(pieces) => { + self.push_undo_snapshot(i, "BREAK"); + self.tabs[i].scene.erase_entities(&[popup.handle]); + for e in pieces { + self.tabs[i].scene.add_entity(e); + } + self.tabs[i].dirty = true; + self.refresh_selected_grips(); + self.refresh_properties(); + self.command_line.push_output("Polyline broken at vertex."); + } + None => self + .command_line + .push_error("Cannot break at this vertex."), + } + return Task::none(); + } // One-shot action — apply immediately. self.push_undo_snapshot(i, item.label); // For Add Leader, the new arrow becomes the last grip; remember diff --git a/src/entities/lwpolyline.rs b/src/entities/lwpolyline.rs index 3d463dc01..f5fd00de3 100644 --- a/src/entities/lwpolyline.rs +++ b/src/entities/lwpolyline.rs @@ -404,6 +404,42 @@ fn tapered_band_verts(pline: &LwPolyline) -> Option Option> { + use acadrust::EntityType; + let n = p.vertices.len(); + if n < 3 || idx >= n { + return None; + } + if p.is_closed { + let mut verts = Vec::with_capacity(n + 1); + verts.extend_from_slice(&p.vertices[idx..]); + verts.extend_from_slice(&p.vertices[..=idx]); + let mut out = p.clone(); + out.common.handle = acadrust::Handle::NULL; + out.is_closed = false; + out.vertices = verts; + return Some(vec![EntityType::LwPolyline(out)]); + } + if idx == 0 || idx == n - 1 { + return None; + } + let mut a = p.clone(); + a.common.handle = acadrust::Handle::NULL; + a.vertices = p.vertices[..=idx].to_vec(); + if let Some(last) = a.vertices.last_mut() { + last.bulge = 0.0; + } + let mut b = p.clone(); + b.common.handle = acadrust::Handle::NULL; + b.vertices = p.vertices[idx..].to_vec(); + Some(vec![EntityType::LwPolyline(a), EntityType::LwPolyline(b)]) +} + fn grips(pline: &LwPolyline) -> Vec { let elev = pline.elevation; let n = pline.vertices.len(); @@ -678,8 +714,10 @@ impl crate::entities::traits::Grippable for LwPolyline { use crate::scene::model::object::{GripMenuAction, GripMenuItem}; let n = self.vertices.len(); if grip_id < n { - // Vertex grip. - return vec![ + // Vertex grip. Break only where a split is possible: any vertex + // of a closed polyline, interior vertices of an open one. + let breakable = n >= 3 && (self.is_closed || (grip_id > 0 && grip_id < n - 1)); + let mut items = vec![ GripMenuItem { label: "Stretch", action: GripMenuAction::Stretch, @@ -693,6 +731,13 @@ impl crate::entities::traits::Grippable for LwPolyline { action: GripMenuAction::RemoveVertex, }, ]; + if breakable { + items.push(GripMenuItem { + label: "Break", + action: GripMenuAction::BreakVertex, + }); + } + return items; } // Segment midpoint grip. let seg = grip_id - n; diff --git a/src/modules/draw/modify/join.rs b/src/modules/draw/modify/join.rs index 58bd10ade..dd50c3e29 100644 --- a/src/modules/draw/modify/join.rs +++ b/src/modules/draw/modify/join.rs @@ -92,15 +92,18 @@ fn v3(p: DVec3) -> Vector3 { Vector3::new(p.x, p.y, p.z) } -/// Build the chain segment for one entity, or `None` for an entity type -/// JOIN can't carry (which aborts the whole join). -fn seg_of(e: &EntityType) -> Option { +/// Build the chain segments for one entity, or `None` for an entity type +/// JOIN can't carry (which aborts the whole join). Lines and arcs contribute +/// one segment; an OPEN polyline contributes one per span (bulges kept), so +/// polylines merge with their neighbours too — the PEDIT Join set always +/// contains the target polyline (#263). A closed polyline can't be joined. +fn segs_of(e: &EntityType) -> Option> { match e { - EntityType::Line(l) => Some(Seg { + EntityType::Line(l) => Some(vec![Seg { a: DVec3::new(l.start.x, l.start.y, l.start.z), b: DVec3::new(l.end.x, l.end.y, l.end.z), bulge: 0.0, - }), + }]), EntityType::Arc(arc) => { // The bulge below assumes the arc lies in a +Z plane; a tilted // or flipped normal would invert the CCW sweep, so reject it. @@ -111,11 +114,42 @@ fn seg_of(e: &EntityType) -> Option { let r = arc.radius; let (sa, ea) = (arc.start_angle, arc.end_angle); let swept = (ea - sa).rem_euclid(std::f64::consts::TAU); - Some(Seg { + Some(vec![Seg { a: DVec3::new(cx + r * sa.cos(), cy + r * sa.sin(), cz), b: DVec3::new(cx + r * ea.cos(), cy + r * ea.sin(), cz), bulge: (swept / 4.0).tan(), - }) + }]) + } + EntityType::LwPolyline(p) => { + if p.is_closed || p.vertices.len() < 2 { + return None; + } + let z = p.elevation; + Some( + p.vertices + .windows(2) + .map(|w| Seg { + a: DVec3::new(w[0].location.x, w[0].location.y, z), + b: DVec3::new(w[1].location.x, w[1].location.y, z), + bulge: w[0].bulge, + }) + .collect(), + ) + } + EntityType::Polyline2D(p) => { + if p.is_closed() || p.vertices.len() < 2 { + return None; + } + Some( + p.vertices + .windows(2) + .map(|w| Seg { + a: DVec3::new(w[0].location.x, w[0].location.y, w[0].location.z), + b: DVec3::new(w[1].location.x, w[1].location.y, w[1].location.z), + bulge: w[0].bulge, + }) + .collect(), + ) } _ => None, } @@ -135,7 +169,7 @@ pub fn join_entities(entities: &[(Handle, &EntityType)]) -> Option<(Vec, let mut segs = Vec::with_capacity(entities.len()); for (_, e) in entities { - segs.push(seg_of(e)?); + segs.extend(segs_of(e)?); } let handles: Vec = entities.iter().map(|(h, _)| *h).collect(); let common = entities[0].1.common().clone(); diff --git a/src/modules/draw/modify/pedit.rs b/src/modules/draw/modify/pedit.rs index 14ce409e5..71488addb 100644 --- a/src/modules/draw/modify/pedit.rs +++ b/src/modules/draw/modify/pedit.rs @@ -1,33 +1,80 @@ -// PEDIT command — edit a polyline entity. +// PEDIT command — edit a polyline entity (#263). // -// Supports LwPolyline and (partially) Polyline2D. -// Subcommands (text input after entity pick): -// C / CLOSE — toggle closed flag -// O / OPEN — clear closed flag -// W — set uniform width (LwPolyline) -// E — enter vertex editing mode (not implemented, use grips) -// J — join (same as JOIN command, triggered as alias) -// X / EXIT — exit +// Flow: +// Select polyline — picking a LINE or ARC instead asks "Turn it into one?" +// (Yes converts in place and continues editing the result). +// Options: Close / Open / Join / Width / Fit / Spline / Decurve / eXit. +// Join gathers more segments (lines / arcs / polylines) and merges every +// contiguous run through the JOIN machinery. (Vertex editing lives on the +// polyline's grips, not in PEDIT.) +// Fit smooths the segments into tangent-blended arcs, Spline replaces the +// shape with a sampled cubic B-spline of the vertex frame, Decurve +// straightens every segment. +use acadrust::entities::LwVertex; +use acadrust::types::Vector2; use acadrust::{EntityType, Handle}; -use glam::DVec3; +use glam::{DVec2, DVec3}; +use rustc_hash::FxHashMap as HashMap; use crate::command::{CadCommand, CmdResult}; +const TAU: f64 = std::f64::consts::TAU; + +/// What PEDIT knows about a pickable entity, captured at dispatch. +#[derive(Clone, Copy)] +pub struct PeditTarget { + /// LwPolyline / Polyline2D — a valid edit target. + pub is_poly: bool, + /// Line / Arc — offered for conversion on pick. + pub convertible: bool, +} + +enum Mode { + PickTarget, + Options, + /// Picked a Line/Arc: asking "Turn it into one? [Yes/No]". + ConvertPrompt(Handle), + AwaitWidth, + /// Join: gathering additional segments; Enter merges. + JoinGather(Vec), +} + pub struct PeditCommand { target: Option, - /// Set once the bare `W` keyword (or the Width button) is chosen: the next - /// text input is parsed as the new width instead of an option. (#304) - awaiting_width: bool, + info: HashMap, + mode: Mode, } impl PeditCommand { - pub fn new() -> Self { + pub fn new(info: HashMap) -> Self { Self { target: None, - awaiting_width: false, + info, + mode: Mode::PickTarget, } } + + /// Adopt a pre-selected entity (pickfirst): a selected polyline skips the + /// pick step, a selected line/arc goes straight to the convert prompt. + pub fn with_preselection(mut self, handles: &[Handle]) -> Self { + for &h in handles { + let Some(info) = self.info.get(&h.value()).copied() else { + continue; + }; + if info.is_poly { + self.target = Some(h); + self.mode = Mode::Options; + break; + } + if info.convertible { + self.mode = Mode::ConvertPrompt(h); + break; + } + } + self + } + } impl CadCommand for PeditCommand { @@ -36,109 +83,205 @@ impl CadCommand for PeditCommand { } fn prompt(&self) -> String { - if self.target.is_none() { - "PEDIT Select polyline:".into() - } else if self.awaiting_width { - "PEDIT Specify new width:".into() - } else { - "PEDIT Enter option:".into() + match &self.mode { + Mode::PickTarget => "PEDIT Select polyline (or a line/arc to convert):".into(), + Mode::ConvertPrompt(_) => { + "PEDIT Object is not a polyline. Turn it into one? [Yes/No] :".into() + } + Mode::AwaitWidth => "PEDIT Specify new width:".into(), + Mode::JoinGather(list) => format!( + "PEDIT Join Select objects to join ({} picked), Enter to merge:", + list.len().saturating_sub(1) + ), + Mode::Options => "PEDIT Enter option:".into(), } } fn options(&self) -> Vec { use crate::command::CmdOption; - if self.target.is_none() || self.awaiting_width { - vec![] - } else { - vec![ + match &self.mode { + Mode::Options => vec![ CmdOption::new("Close", "C"), CmdOption::new("Open", "O"), + CmdOption::new("Join", "J"), CmdOption::new("Width", "W"), + CmdOption::new("Fit", "F"), + CmdOption::new("Spline", "S"), + CmdOption::new("Decurve", "D"), CmdOption::new("eXit", "X"), - ] + ], + Mode::ConvertPrompt(_) => { + vec![CmdOption::new("Yes", "Y"), CmdOption::new("No", "N")] + } + Mode::JoinGather(_) => vec![CmdOption::enter("Join")], + _ => vec![], } } fn needs_entity_pick(&self) -> bool { - self.target.is_none() + matches!(self.mode, Mode::PickTarget) + } + + fn is_selection_gathering(&self) -> bool { + // Join uses the normal selection system, so single picks AND + // window/crossing boxes both gather objects. + matches!(self.mode, Mode::JoinGather(_)) + } + + fn on_selection_complete(&mut self, handles: Vec) -> CmdResult { + if let (Some(target), Mode::JoinGather(list)) = (self.target, &mut self.mode) { + list.clear(); + list.push(target); + for h in handles { + if h != target && self.info.contains_key(&h.value()) && !list.contains(&h) { + list.push(h); + } + } + } + CmdResult::NeedPoint } fn on_entity_pick(&mut self, handle: Handle, _pt: DVec3) -> CmdResult { if handle.is_null() { return CmdResult::NeedPoint; } - self.target = Some(handle); - CmdResult::NeedPoint + match &mut self.mode { + Mode::PickTarget => { + let Some(info) = self.info.get(&handle.value()).copied() else { + return CmdResult::NeedPoint; + }; + if info.is_poly { + self.target = Some(handle); + self.mode = Mode::Options; + } else if info.convertible { + self.mode = Mode::ConvertPrompt(handle); + } + CmdResult::NeedPoint + } + _ => CmdResult::NeedPoint, + } + } + + fn on_entity_replaced(&mut self, old: Handle, new_handles: &[Handle]) { + // A Yes-conversion (or Break) replaced the entity — adopt the first + // piece as the live target and carry its bookkeeping over. + if let Some(&nh) = new_handles.first() { + self.info.remove(&old.value()); + self.info.insert( + nh.value(), + PeditTarget { + is_poly: true, + convertible: false, + }, + ); + self.target = Some(nh); + self.mode = Mode::Options; + } } fn wants_text_input(&self) -> bool { - self.target.is_some() + !matches!(self.mode, Mode::PickTarget) } fn on_text_input(&mut self, text: &str) -> Option { - let handle = self.target?; let up = text.trim().to_uppercase(); - - // Width sub-step: the previous input was a bare `W` (typed or via the - // Width button), so this input is the width value. (#304) - if self.awaiting_width { - let w: f64 = up - .replace(',', ".") - .parse() - .ok() - .filter(|&v: &f64| v >= 0.0)?; - self.awaiting_width = false; - return Some(CmdResult::PeditOp { - handle, - op: PeditOp::SetWidth(w), - }); - } - - match up.as_str() { - "X" | "EXIT" => return Some(CmdResult::Cancel), - "C" | "CLOSE" => { - return Some(CmdResult::PeditOp { + match &mut self.mode { + Mode::PickTarget => None, + Mode::ConvertPrompt(handle) => { + let handle = *handle; + match up.as_str() { + "Y" | "YES" | "" => Some(CmdResult::PeditOp { + handle, + op: PeditOp::ConvertToPolyline, + }), + "N" | "NO" => { + self.mode = Mode::PickTarget; + Some(CmdResult::NeedPoint) + } + _ => Some(CmdResult::NeedPoint), + } + } + Mode::AwaitWidth => { + let handle = self.target?; + let w: f64 = up + .replace(',', ".") + .parse() + .ok() + .filter(|&v: &f64| v >= 0.0)?; + self.mode = Mode::Options; + Some(CmdResult::PeditOp { handle, - op: PeditOp::SetClosed(true), + op: PeditOp::SetWidth(w), }) } - "O" | "OPEN" => { - return Some(CmdResult::PeditOp { - handle, - op: PeditOp::SetClosed(false), - }) + Mode::JoinGather(_) => None, + Mode::Options => { + let handle = self.target?; + match up.as_str() { + "X" | "EXIT" => Some(CmdResult::Cancel), + "C" | "CLOSE" => Some(CmdResult::PeditOp { + handle, + op: PeditOp::SetClosed(true), + }), + "O" | "OPEN" => Some(CmdResult::PeditOp { + handle, + op: PeditOp::SetClosed(false), + }), + "W" | "WIDTH" => { + self.mode = Mode::AwaitWidth; + Some(CmdResult::NeedPoint) + } + "J" | "JOIN" => { + self.mode = Mode::JoinGather(vec![handle]); + Some(CmdResult::NeedPoint) + } + "F" | "FIT" => Some(CmdResult::PeditOp { + handle, + op: PeditOp::Fit, + }), + "S" | "SPLINE" => Some(CmdResult::PeditOp { + handle, + op: PeditOp::Spline, + }), + "D" | "DECURVE" => Some(CmdResult::PeditOp { + handle, + op: PeditOp::Decurve, + }), + _ => { + // Inline shorthand `W `. + if let Some(rest) = up.strip_prefix("W ") { + let w: f64 = rest.trim().replace(',', ".").parse().ok()?; + if w >= 0.0 { + return Some(CmdResult::PeditOp { + handle, + op: PeditOp::SetWidth(w), + }); + } + } + None + } + } } - "W" | "WIDTH" => { - // Bare width keyword (the Width button): prompt for the value - // rather than doing nothing. - self.awaiting_width = true; - return Some(CmdResult::NeedPoint); - } - _ => {} } - - // Inline shorthand `W `. - if let Some(rest) = up.strip_prefix("W ") { - let w: f64 = rest - .trim() - .replace(',', ".") - .parse() - .ok() - .filter(|&v: &f64| v >= 0.0)?; - return Some(CmdResult::PeditOp { - handle, - op: PeditOp::SetWidth(w), - }); - } - - None } fn on_point(&mut self, _pt: DVec3) -> CmdResult { CmdResult::NeedPoint } + fn on_enter(&mut self) -> CmdResult { - CmdResult::Cancel + match &self.mode { + Mode::JoinGather(list) if list.len() >= 2 => CmdResult::JoinEntities(list.clone()), + Mode::JoinGather(_) => { + self.mode = Mode::Options; + CmdResult::NeedPoint + } + Mode::ConvertPrompt(h) => CmdResult::PeditOp { + handle: *h, + op: PeditOp::ConvertToPolyline, + }, + _ => CmdResult::Cancel, + } } } @@ -148,9 +291,14 @@ impl CadCommand for PeditCommand { pub enum PeditOp { SetClosed(bool), SetWidth(f64), + /// Replace the picked Line/Arc with an equivalent LwPolyline (#263). + ConvertToPolyline, + Fit, + Spline, + Decurve, } -// ── Apply logic ──────────────────────────────────────────────────────────── +// ── Apply logic (pure entity edits; driver handles convert/break/marker) ── pub fn apply_pedit(entity: &mut EntityType, op: &PeditOp) -> bool { match op { @@ -180,9 +328,271 @@ pub fn apply_pedit(entity: &mut EntityType, op: &PeditOp) -> bool { } _ => false, }, + PeditOp::Fit => match entity { + EntityType::LwPolyline(p) => fit_curve(p), + _ => false, + }, + PeditOp::Spline => match entity { + EntityType::LwPolyline(p) => spline_smooth(p), + _ => false, + }, + PeditOp::Decurve => match entity { + EntityType::LwPolyline(p) => { + for v in &mut p.vertices { + v.bulge = 0.0; + } + true + } + _ => false, + }, + // Handled by the driver (it replaces the entity, not edits in place). + PeditOp::ConvertToPolyline => false, } } +/// A Line or Arc as an equivalent 2-vertex LwPolyline (common carried over, +/// handle NULL for the replace flow). `None` for anything else. +pub fn convert_to_polyline(entity: &EntityType) -> Option { + let mut pl = acadrust::LwPolyline::new(); + match entity { + EntityType::Line(l) => { + pl.common = l.common.clone(); + pl.vertices = vec![ + LwVertex::new(Vector2::new(l.start.x, l.start.y)), + LwVertex::new(Vector2::new(l.end.x, l.end.y)), + ]; + } + EntityType::Arc(a) => { + pl.common = a.common.clone(); + let (sa, ea) = (a.start_angle, a.end_angle); + let sweep = { + let s = (ea - sa).rem_euclid(TAU); + if s.abs() < 1e-12 { + TAU + } else { + s + } + }; + let p0 = ( + a.center.x + a.radius * sa.cos(), + a.center.y + a.radius * sa.sin(), + ); + let p1 = ( + a.center.x + a.radius * ea.cos(), + a.center.y + a.radius * ea.sin(), + ); + let mut v0 = LwVertex::new(Vector2::new(p0.0, p0.1)); + v0.bulge = (sweep / 4.0).tan(); + pl.vertices = vec![v0, LwVertex::new(Vector2::new(p1.0, p1.1))]; + } + _ => return None, + } + pl.common.handle = Handle::NULL; + Some(EntityType::LwPolyline(pl)) +} + +// ── Curve fitting ───────────────────────────────────────────────────────── + +fn vert_xy(v: &LwVertex) -> DVec2 { + DVec2::new(v.location.x, v.location.y) +} + +/// Wrap an angle into (-pi, pi]. +fn wrap_angle(mut a: f64) -> f64 { + while a > std::f64::consts::PI { + a -= TAU; + } + while a <= -std::f64::consts::PI { + a += TAU; + } + a +} + +/// Bulge of the arc `a` -> `b` whose tangent AT `a` is `t` (entry form). +fn bulge_entry(a: DVec2, t: DVec2, b: DVec2) -> f64 { + let d = b - a; + if d.length_squared() < 1e-12 { + return 0.0; + } + (wrap_angle(d.y.atan2(d.x) - t.y.atan2(t.x)) / 2.0).tan() +} + +/// Bulge of the arc `a` -> `b` whose tangent AT `b` is `t` (exit form). +fn bulge_exit(a: DVec2, b: DVec2, t: DVec2) -> f64 { + let d = b - a; + if d.length_squared() < 1e-12 { + return 0.0; + } + (wrap_angle(t.y.atan2(t.x) - d.y.atan2(d.x)) / 2.0).tan() +} + +/// PEDIT Fit: replace every segment with a BIARC — two arcs that leave the +/// start vertex along its tangent, meet each other tangentially at an +/// inserted knee vertex, and arrive at the end vertex along ITS tangent. +/// Both segments at a vertex share that vertex's tangent (the average of the +/// neighbouring chords), so the whole run is tangent-continuous — arcs +/// mutually tangent everywhere, which a single arc per segment cannot do. +fn fit_curve(p: &mut acadrust::LwPolyline) -> bool { + let n = p.vertices.len(); + if n < 3 { + return false; + } + let pts: Vec = p.vertices.iter().map(vert_xy).collect(); + let chord = |i: usize| -> DVec2 { + let a = pts[i % n]; + let b = pts[(i + 1) % n]; + (b - a).normalize_or_zero() + }; + // Per-vertex tangents. + let tangent = |i: usize| -> DVec2 { + if !p.is_closed && i == 0 { + return chord(0); + } + if !p.is_closed && i == n - 1 { + return chord(n - 2); + } + let prev = chord((i + n - 1) % n); + let cur = chord(i % n); + let sum = prev + cur; + if sum.length_squared() < 1e-12 { + cur + } else { + sum.normalize() + } + }; + let seg_count = if p.is_closed { n } else { n - 1 }; + let mut out: Vec = Vec::with_capacity(seg_count * 2 + 1); + for i in 0..seg_count { + let p0 = pts[i % n]; + let p1 = pts[(i + 1) % n]; + let t0 = tangent(i % n); + let t1 = tangent((i + 1) % n); + let d = p1 - p0; + let src = &p.vertices[i % n]; + let mut push = |q: DVec2, bulge: f64| { + let mut v = LwVertex::new(Vector2::new(q.x, q.y)); + v.bulge = bulge; + v.start_width = src.start_width; + v.end_width = src.end_width; + out.push(v); + }; + if d.length_squared() < 1e-12 { + push(p0, 0.0); + continue; + } + // Both tangents already aligned with the chord: keep it straight. + let dn = d.normalize(); + if (dn - t0).length_squared() < 1e-12 && (dn - t1).length_squared() < 1e-12 { + push(p0, 0.0); + continue; + } + // Classic equal-parameter biarc: apexes A = p0 + k*t0, B = p1 - k*t1 + // and the knee M at their midpoint. Tangency at M needs |AB| = 2k, + // i.e. 2(1 - t0.t1) k^2 + 2 (d.(t0+t1)) k - |d|^2 = 0 — A and B are + // then the tangent-line apexes of their arcs, so both arcs' tangents + // at M run along AB and the pair is tangent-continuous. + let dot_tt = t0.dot(t1).clamp(-1.0, 1.0); + let b_lin = d.dot(t0 + t1); + let a_quad = 2.0 * (1.0 - dot_tt); + let k = if a_quad.abs() > 1e-9 { + let disc = b_lin * b_lin + a_quad * d.length_squared(); + if disc < 0.0 { + push(p0, bulge_entry(p0, t0, p1)); + continue; + } + (-b_lin + disc.sqrt()) / a_quad + } else if b_lin.abs() > 1e-9 { + // Parallel tangents: the quadratic degenerates to one root. + d.length_squared() / (2.0 * b_lin) + } else { + // Anti-parallel S with no along-tangent reach: symmetric split. + (d.length_squared() / 4.0).sqrt() + }; + if !k.is_finite() || k <= 1e-9 { + push(p0, bulge_entry(p0, t0, p1)); + continue; + } + let m = (p0 + t0 * k + p1 - t1 * k) * 0.5; + push(p0, bulge_entry(p0, t0, m)); + push(m, bulge_exit(m, p1, t1)); + } + if !p.is_closed { + let mut last = p.vertices[n - 1].clone(); + last.bulge = 0.0; + out.push(last); + } + if out.len() < 2 { + return false; + } + p.vertices = out; + true +} + +/// PEDIT Spline: replace the shape with a sampled uniform cubic B-spline of +/// the vertex frame (8 samples per span; a closed frame wraps around). +fn spline_smooth(p: &mut acadrust::LwPolyline) -> bool { + let n = p.vertices.len(); + if n < 3 { + return false; + } + let pts: Vec = p.vertices.iter().map(vert_xy).collect(); + const S: usize = 8; + let bspline = |p0: DVec2, p1: DVec2, p2: DVec2, p3: DVec2, t: f64| -> DVec2 { + let t2 = t * t; + let t3 = t2 * t; + (p0 * (-t3 + 3.0 * t2 - 3.0 * t + 1.0) + + p1 * (3.0 * t3 - 6.0 * t2 + 4.0) + + p2 * (-3.0 * t3 + 3.0 * t2 + 3.0 * t + 1.0) + + p3 * t3) + / 6.0 + }; + let mut out: Vec = Vec::new(); + if p.is_closed { + for i in 0..n { + let p0 = pts[(i + n - 1) % n]; + let p1 = pts[i]; + let p2 = pts[(i + 1) % n]; + let p3 = pts[(i + 2) % n]; + for s in 0..S { + out.push(bspline(p0, p1, p2, p3, s as f64 / S as f64)); + } + } + } else { + // Clamp the ends by repeating the end control points so the curve + // starts and finishes on them. + let at = |i: isize| -> DVec2 { pts[i.clamp(0, n as isize - 1) as usize] }; + for i in -1..(n as isize - 2) { + let (p0, p1, p2, p3) = (at(i), at(i + 1), at(i + 2), at(i + 3)); + let last_span = i == n as isize - 3; + let steps = if last_span { S + 1 } else { S }; + for s in 0..steps { + out.push(bspline(p0, p1, p2, p3, s as f64 / S as f64)); + } + } + // Pin the exact endpoints. + if let (Some(v), Some(q)) = (out.first_mut(), pts.first()) { + *v = *q; + } + if let (Some(v), Some(q)) = (out.last_mut(), pts.last()) { + *v = *q; + } + } + if out.len() < 2 { + return false; + } + let w = p.constant_width; + p.vertices = out + .into_iter() + .map(|q| { + let mut v = LwVertex::new(Vector2::new(q.x, q.y)); + v.start_width = w; + v.end_width = w; + v + }) + .collect(); + true +} + // ── Autocomplete registry ───────────────────────────────── inventory::submit!(crate::command::CommandRegistration { names: &["PEDIT"] }); // PeditCommand diff --git a/src/scene/model/object.rs b/src/scene/model/object.rs index abff61a74..64499702c 100644 --- a/src/scene/model/object.rs +++ b/src/scene/model/object.rs @@ -139,6 +139,9 @@ pub enum GripMenuAction { AddVertex, RemoveVertex, ConvertToArc, + /// Split the polyline at this vertex (a closed one opens here; an open + /// one splits into two). Handled by the driver — it replaces the entity. + BreakVertex, ConvertToLine, StretchVertex, AddLeader,