// PEDIT command — edit a polyline entity (#263). // // 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::{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, info: HashMap, mode: Mode, } impl PeditCommand { pub fn new(info: HashMap) -> Self { Self { target: None, 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 { fn name(&self) -> &'static str { "PEDIT" } fn prompt(&self) -> String { 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; 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 { 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; } 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 { !matches!(self.mode, Mode::PickTarget) } fn on_text_input(&mut self, text: &str) -> Option { let up = text.trim().to_uppercase(); 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::SetWidth(w), }) } 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 } } } } } fn on_point(&mut self, _pt: DVec3) -> CmdResult { CmdResult::NeedPoint } fn on_enter(&mut self) -> CmdResult { 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, } } } // ── Op enum (used in CmdResult) ──────────────────────────────────────────── #[derive(Clone)] pub enum PeditOp { SetClosed(bool), SetWidth(f64), /// Replace the picked Line/Arc with an equivalent LwPolyline (#263). ConvertToPolyline, Fit, Spline, Decurve, } // ── Apply logic (pure entity edits; driver handles convert/break/marker) ── pub fn apply_pedit(entity: &mut EntityType, op: &PeditOp) -> bool { match op { PeditOp::SetClosed(closed) => match entity { EntityType::LwPolyline(p) => { p.is_closed = *closed; true } EntityType::Polyline2D(p) => { if *closed { p.close(); } else { p.flags.set_closed(false); } true } _ => false, }, PeditOp::SetWidth(w) => match entity { EntityType::LwPolyline(p) => { p.constant_width = *w; for v in &mut p.vertices { v.start_width = *w; v.end_width = *w; } true } _ => 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