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]>
This commit is contained in:
Hakan SevenandClaude Fable 5 committed 2026-07-22 20:06:04 +03:00
1 parent b32a9b4083
commit e7eb84b514
7 files changed
+705 -112

No files matched your search

+51 -18
View File
@@ -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;
+43 -2
View File
@@ -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<acadrust::Handle> =
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<acadrust::Handle> =
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));
}
+27
View File
@@ -935,6 +935,33 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
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
+47 -2
View File
@@ -404,6 +404,42 @@ fn tapered_band_verts(pline: &LwPolyline) -> Option<Vec<([f64; 2], f64, f64, f64
}
}
/// Split at vertex `idx`: a closed polyline re-opens there (one piece); an
/// open one splits into two (interior vertices only). `None` when invalid.
pub(crate) fn break_at_vertex(
p: &acadrust::LwPolyline,
idx: usize,
) -> Option<Vec<acadrust::EntityType>> {
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<GripDef> {
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;
+42 -8
View File
@@ -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<Seg> {
/// 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<Vec<Seg>> {
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<Seg> {
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<Handle>,
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<Handle> = entities.iter().map(|(h, _)| *h).collect();
let common = entities[0].1.common().clone();
+492 -82
View File
@@ -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 <width> — 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<Handle>),
}
pub struct PeditCommand {
target: Option<Handle>,
/// 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<u64, PeditTarget>,
mode: Mode,
}
impl PeditCommand {
pub fn new() -> Self {
pub fn new(info: HashMap<u64, PeditTarget>) -> 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] <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;
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<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;
}
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<CmdResult> {
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 <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
}
}
}
"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 <value>`.
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<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
+3
View File
@@ -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,