Files
OneCAD-Draft/src/modules/draw/modify/pedit.rs
T
Hakan SevenandClaude Fable 5 e7eb84b514 feat(pedit): convert prompt, join, fit/spline/decurve; join fixes (#263)
PEDIT reworked around a mode machine:

- Picking a line/arc asks "Object is not a polyline. Turn it into one?
  [Yes/No] <Y>" — Yes converts in place (arc keeps its bulge) and
  editing continues on the result. Pickfirst: an already-selected
  polyline skips the select step, a selected line/arc goes straight to
  the convert prompt.
- Join gathers with the normal selection system (single picks AND
  window/crossing boxes) and merges through the JOIN machinery.
- Fit replaces every segment with a classic equal-parameter BIARC: two
  arcs per segment meeting tangentially at an inserted knee, both
  segments at a vertex sharing that vertex's tangent — the whole run is
  tangent-continuous (verified: zero tangent jump on L/closed/zigzag/S
  shapes). Spline resamples the vertex frame as a uniform cubic
  B-spline (8 samples per span); Decurve straightens every bulge.
- Earlier hardening kept: undo snapshot before the mutation, immediate
  repaint, polyline-only pick validation.
- Vertex editing lives on grips, so no Edit-vertex submode; instead the
  vertex grip hover menu gains Break — a closed polyline opens at the
  vertex, an open one splits into two (interior vertices only),
  handled driver-side as an entity replacement.

JOIN itself: segs_of now emits one segment per span of an OPEN
polyline (bulges kept), so polylines merge with their neighbours —
PEDIT Join's set always contains the target polyline and used to abort
every time; plain JOIN had the same gap. JOIN also honours pickfirst:
launched with two or more objects selected it joins them immediately.

Closes #263

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-22 20:06:04 +03:00

599 lines
20 KiB
Rust

// 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<Handle>),
}
pub struct PeditCommand {
target: Option<Handle>,
info: HashMap<u64, PeditTarget>,
mode: Mode,
}
impl PeditCommand {
pub fn new(info: HashMap<u64, PeditTarget>) -> 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] <Y>:".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<crate::command::CmdOption> {
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<Handle>) -> 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<CmdResult> {
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 <value>`.
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<EntityType> {
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<DVec2> = 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<LwVertex> = 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<DVec2> = 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<DVec2> = 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