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 <noreply@anthropic.com>
This commit is contained in:
parent
b32a9b4083
commit
e7eb84b514
7 changed files with 704 additions and 111 deletions
|
|
@ -1277,25 +1277,58 @@ impl OpenCADStudio {
|
||||||
self.restore_pre_cmd_tangent();
|
self.restore_pre_cmd_tangent();
|
||||||
}
|
}
|
||||||
CmdResult::PeditOp { handle, op } => {
|
CmdResult::PeditOp { handle, op } => {
|
||||||
use crate::modules::draw::modify::pedit::apply_pedit;
|
use crate::modules::draw::modify::pedit::{
|
||||||
let changed = self.tabs[i]
|
apply_pedit, convert_to_polyline, PeditOp,
|
||||||
.scene
|
};
|
||||||
.document
|
match &op {
|
||||||
.get_entity_mut(handle)
|
// The convert replaces the entity (new handle).
|
||||||
.map(|e| apply_pedit(e, &op))
|
PeditOp::ConvertToPolyline => {
|
||||||
.unwrap_or(false);
|
let converted = self.tabs[i]
|
||||||
if changed {
|
.scene
|
||||||
self.push_undo_snapshot(i, "PEDIT");
|
.document
|
||||||
self.tabs[i].dirty = true;
|
.get_entity(handle)
|
||||||
self.command_line.push_output("PEDIT: applied.");
|
.and_then(convert_to_polyline);
|
||||||
self.refresh_properties();
|
match converted {
|
||||||
} else {
|
Some(pl) => {
|
||||||
self.command_line
|
return self.apply_cmd_result(CmdResult::ReplaceEntity(
|
||||||
.push_error("PEDIT: operation not applicable to this entity.");
|
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) => {
|
CmdResult::JoinEntities(handles) => {
|
||||||
use crate::modules::draw::modify::join::join_entities;
|
use crate::modules::draw::modify::join::join_entities;
|
||||||
|
|
|
||||||
|
|
@ -223,6 +223,15 @@ impl OpenCADStudio {
|
||||||
// ── Break / Join ─────────────────────────────────────────────────
|
// ── Break / Join ─────────────────────────────────────────────────
|
||||||
"JOIN" => {
|
"JOIN" => {
|
||||||
use crate::modules::draw::modify::join::JoinCommand;
|
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();
|
let cmd = JoinCommand::new();
|
||||||
self.command_line.push_info(&cmd.prompt());
|
self.command_line.push_info(&cmd.prompt());
|
||||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||||
|
|
@ -243,8 +252,40 @@ impl OpenCADStudio {
|
||||||
}
|
}
|
||||||
|
|
||||||
"PEDIT" => {
|
"PEDIT" => {
|
||||||
use crate::modules::draw::modify::pedit::PeditCommand;
|
use crate::modules::draw::modify::pedit::{PeditCommand, PeditTarget};
|
||||||
let cmd_obj = PeditCommand::new();
|
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.command_line.push_info(&cmd_obj.prompt());
|
||||||
self.tabs[i].active_cmd = Some(Box::new(cmd_obj));
|
self.tabs[i].active_cmd = Some(Box::new(cmd_obj));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -935,6 +935,33 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||||
self.command_line.push_info(&format!("{label}:"));
|
self.command_line.push_info(&format!("{label}:"));
|
||||||
return self.focus_cmd_input();
|
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.
|
// One-shot action — apply immediately.
|
||||||
self.push_undo_snapshot(i, item.label);
|
self.push_undo_snapshot(i, item.label);
|
||||||
// For Add Leader, the new arrow becomes the last grip; remember
|
// For Add Leader, the new arrow becomes the last grip; remember
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
fn grips(pline: &LwPolyline) -> Vec<GripDef> {
|
||||||
let elev = pline.elevation;
|
let elev = pline.elevation;
|
||||||
let n = pline.vertices.len();
|
let n = pline.vertices.len();
|
||||||
|
|
@ -678,8 +714,10 @@ impl crate::entities::traits::Grippable for LwPolyline {
|
||||||
use crate::scene::model::object::{GripMenuAction, GripMenuItem};
|
use crate::scene::model::object::{GripMenuAction, GripMenuItem};
|
||||||
let n = self.vertices.len();
|
let n = self.vertices.len();
|
||||||
if grip_id < n {
|
if grip_id < n {
|
||||||
// Vertex grip.
|
// Vertex grip. Break only where a split is possible: any vertex
|
||||||
return vec![
|
// 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 {
|
GripMenuItem {
|
||||||
label: "Stretch",
|
label: "Stretch",
|
||||||
action: GripMenuAction::Stretch,
|
action: GripMenuAction::Stretch,
|
||||||
|
|
@ -693,6 +731,13 @@ impl crate::entities::traits::Grippable for LwPolyline {
|
||||||
action: GripMenuAction::RemoveVertex,
|
action: GripMenuAction::RemoveVertex,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
if breakable {
|
||||||
|
items.push(GripMenuItem {
|
||||||
|
label: "Break",
|
||||||
|
action: GripMenuAction::BreakVertex,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items;
|
||||||
}
|
}
|
||||||
// Segment midpoint grip.
|
// Segment midpoint grip.
|
||||||
let seg = grip_id - n;
|
let seg = grip_id - n;
|
||||||
|
|
|
||||||
|
|
@ -92,15 +92,18 @@ fn v3(p: DVec3) -> Vector3 {
|
||||||
Vector3::new(p.x, p.y, p.z)
|
Vector3::new(p.x, p.y, p.z)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the chain segment for one entity, or `None` for an entity type
|
/// Build the chain segments for one entity, or `None` for an entity type
|
||||||
/// JOIN can't carry (which aborts the whole join).
|
/// JOIN can't carry (which aborts the whole join). Lines and arcs contribute
|
||||||
fn seg_of(e: &EntityType) -> Option<Seg> {
|
/// 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 {
|
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),
|
a: DVec3::new(l.start.x, l.start.y, l.start.z),
|
||||||
b: DVec3::new(l.end.x, l.end.y, l.end.z),
|
b: DVec3::new(l.end.x, l.end.y, l.end.z),
|
||||||
bulge: 0.0,
|
bulge: 0.0,
|
||||||
}),
|
}]),
|
||||||
EntityType::Arc(arc) => {
|
EntityType::Arc(arc) => {
|
||||||
// The bulge below assumes the arc lies in a +Z plane; a tilted
|
// The bulge below assumes the arc lies in a +Z plane; a tilted
|
||||||
// or flipped normal would invert the CCW sweep, so reject it.
|
// 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 r = arc.radius;
|
||||||
let (sa, ea) = (arc.start_angle, arc.end_angle);
|
let (sa, ea) = (arc.start_angle, arc.end_angle);
|
||||||
let swept = (ea - sa).rem_euclid(std::f64::consts::TAU);
|
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),
|
a: DVec3::new(cx + r * sa.cos(), cy + r * sa.sin(), cz),
|
||||||
b: DVec3::new(cx + r * ea.cos(), cy + r * ea.sin(), cz),
|
b: DVec3::new(cx + r * ea.cos(), cy + r * ea.sin(), cz),
|
||||||
bulge: (swept / 4.0).tan(),
|
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,
|
_ => None,
|
||||||
}
|
}
|
||||||
|
|
@ -135,7 +169,7 @@ pub fn join_entities(entities: &[(Handle, &EntityType)]) -> Option<(Vec<Handle>,
|
||||||
|
|
||||||
let mut segs = Vec::with_capacity(entities.len());
|
let mut segs = Vec::with_capacity(entities.len());
|
||||||
for (_, e) in entities {
|
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 handles: Vec<Handle> = entities.iter().map(|(h, _)| *h).collect();
|
||||||
let common = entities[0].1.common().clone();
|
let common = entities[0].1.common().clone();
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,80 @@
|
||||||
// PEDIT command — edit a polyline entity.
|
// PEDIT command — edit a polyline entity (#263).
|
||||||
//
|
//
|
||||||
// Supports LwPolyline and (partially) Polyline2D.
|
// Flow:
|
||||||
// Subcommands (text input after entity pick):
|
// Select polyline — picking a LINE or ARC instead asks "Turn it into one?"
|
||||||
// C / CLOSE — toggle closed flag
|
// (Yes converts in place and continues editing the result).
|
||||||
// O / OPEN — clear closed flag
|
// Options: Close / Open / Join / Width / Fit / Spline / Decurve / eXit.
|
||||||
// W <width> — set uniform width (LwPolyline)
|
// Join gathers more segments (lines / arcs / polylines) and merges every
|
||||||
// E — enter vertex editing mode (not implemented, use grips)
|
// contiguous run through the JOIN machinery. (Vertex editing lives on the
|
||||||
// J — join (same as JOIN command, triggered as alias)
|
// polyline's grips, not in PEDIT.)
|
||||||
// X / EXIT — exit
|
// 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 acadrust::{EntityType, Handle};
|
||||||
use glam::DVec3;
|
use glam::{DVec2, DVec3};
|
||||||
|
use rustc_hash::FxHashMap as HashMap;
|
||||||
|
|
||||||
use crate::command::{CadCommand, CmdResult};
|
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 {
|
pub struct PeditCommand {
|
||||||
target: Option<Handle>,
|
target: Option<Handle>,
|
||||||
/// Set once the bare `W` keyword (or the Width button) is chosen: the next
|
info: HashMap<u64, PeditTarget>,
|
||||||
/// text input is parsed as the new width instead of an option. (#304)
|
mode: Mode,
|
||||||
awaiting_width: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PeditCommand {
|
impl PeditCommand {
|
||||||
pub fn new() -> Self {
|
pub fn new(info: HashMap<u64, PeditTarget>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
target: None,
|
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 {
|
impl CadCommand for PeditCommand {
|
||||||
|
|
@ -36,109 +83,205 @@ impl CadCommand for PeditCommand {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn prompt(&self) -> String {
|
fn prompt(&self) -> String {
|
||||||
if self.target.is_none() {
|
match &self.mode {
|
||||||
"PEDIT Select polyline:".into()
|
Mode::PickTarget => "PEDIT Select polyline (or a line/arc to convert):".into(),
|
||||||
} else if self.awaiting_width {
|
Mode::ConvertPrompt(_) => {
|
||||||
"PEDIT Specify new width:".into()
|
"PEDIT Object is not a polyline. Turn it into one? [Yes/No] <Y>:".into()
|
||||||
} else {
|
}
|
||||||
"PEDIT Enter option:".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> {
|
fn options(&self) -> Vec<crate::command::CmdOption> {
|
||||||
use crate::command::CmdOption;
|
use crate::command::CmdOption;
|
||||||
if self.target.is_none() || self.awaiting_width {
|
match &self.mode {
|
||||||
vec![]
|
Mode::Options => vec![
|
||||||
} else {
|
|
||||||
vec![
|
|
||||||
CmdOption::new("Close", "C"),
|
CmdOption::new("Close", "C"),
|
||||||
CmdOption::new("Open", "O"),
|
CmdOption::new("Open", "O"),
|
||||||
|
CmdOption::new("Join", "J"),
|
||||||
CmdOption::new("Width", "W"),
|
CmdOption::new("Width", "W"),
|
||||||
|
CmdOption::new("Fit", "F"),
|
||||||
|
CmdOption::new("Spline", "S"),
|
||||||
|
CmdOption::new("Decurve", "D"),
|
||||||
CmdOption::new("eXit", "X"),
|
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 {
|
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 {
|
fn on_entity_pick(&mut self, handle: Handle, _pt: DVec3) -> CmdResult {
|
||||||
if handle.is_null() {
|
if handle.is_null() {
|
||||||
return CmdResult::NeedPoint;
|
return CmdResult::NeedPoint;
|
||||||
}
|
}
|
||||||
self.target = Some(handle);
|
match &mut self.mode {
|
||||||
CmdResult::NeedPoint
|
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 {
|
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> {
|
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||||
let handle = self.target?;
|
|
||||||
let up = text.trim().to_uppercase();
|
let up = text.trim().to_uppercase();
|
||||||
|
match &mut self.mode {
|
||||||
// Width sub-step: the previous input was a bare `W` (typed or via the
|
Mode::PickTarget => None,
|
||||||
// Width button), so this input is the width value. (#304)
|
Mode::ConvertPrompt(handle) => {
|
||||||
if self.awaiting_width {
|
let handle = *handle;
|
||||||
let w: f64 = up
|
match up.as_str() {
|
||||||
.replace(',', ".")
|
"Y" | "YES" | "" => Some(CmdResult::PeditOp {
|
||||||
.parse()
|
handle,
|
||||||
.ok()
|
op: PeditOp::ConvertToPolyline,
|
||||||
.filter(|&v: &f64| v >= 0.0)?;
|
}),
|
||||||
self.awaiting_width = false;
|
"N" | "NO" => {
|
||||||
return Some(CmdResult::PeditOp {
|
self.mode = Mode::PickTarget;
|
||||||
handle,
|
Some(CmdResult::NeedPoint)
|
||||||
op: PeditOp::SetWidth(w),
|
}
|
||||||
});
|
_ => Some(CmdResult::NeedPoint),
|
||||||
}
|
}
|
||||||
|
}
|
||||||
match up.as_str() {
|
Mode::AwaitWidth => {
|
||||||
"X" | "EXIT" => return Some(CmdResult::Cancel),
|
let handle = self.target?;
|
||||||
"C" | "CLOSE" => {
|
let w: f64 = up
|
||||||
return Some(CmdResult::PeditOp {
|
.replace(',', ".")
|
||||||
|
.parse()
|
||||||
|
.ok()
|
||||||
|
.filter(|&v: &f64| v >= 0.0)?;
|
||||||
|
self.mode = Mode::Options;
|
||||||
|
Some(CmdResult::PeditOp {
|
||||||
handle,
|
handle,
|
||||||
op: PeditOp::SetClosed(true),
|
op: PeditOp::SetWidth(w),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
"O" | "OPEN" => {
|
Mode::JoinGather(_) => None,
|
||||||
return Some(CmdResult::PeditOp {
|
Mode::Options => {
|
||||||
handle,
|
let handle = self.target?;
|
||||||
op: PeditOp::SetClosed(false),
|
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 {
|
fn on_point(&mut self, _pt: DVec3) -> CmdResult {
|
||||||
CmdResult::NeedPoint
|
CmdResult::NeedPoint
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_enter(&mut self) -> CmdResult {
|
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 {
|
pub enum PeditOp {
|
||||||
SetClosed(bool),
|
SetClosed(bool),
|
||||||
SetWidth(f64),
|
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 {
|
pub fn apply_pedit(entity: &mut EntityType, op: &PeditOp) -> bool {
|
||||||
match op {
|
match op {
|
||||||
|
|
@ -180,9 +328,271 @@ pub fn apply_pedit(entity: &mut EntityType, op: &PeditOp) -> bool {
|
||||||
}
|
}
|
||||||
_ => false,
|
_ => 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 ─────────────────────────────────
|
// ── Autocomplete registry ─────────────────────────────────
|
||||||
inventory::submit!(crate::command::CommandRegistration { names: &["PEDIT"] }); // PeditCommand
|
inventory::submit!(crate::command::CommandRegistration { names: &["PEDIT"] }); // PeditCommand
|
||||||
|
|
|
||||||
|
|
@ -139,6 +139,9 @@ pub enum GripMenuAction {
|
||||||
AddVertex,
|
AddVertex,
|
||||||
RemoveVertex,
|
RemoveVertex,
|
||||||
ConvertToArc,
|
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,
|
ConvertToLine,
|
||||||
StretchVertex,
|
StretchVertex,
|
||||||
AddLeader,
|
AddLeader,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue