forked from 1b-agent/opencadstudio-upstream
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:
1 parent
b32a9b4083
commit
e7eb84b514
7 files changed
+705
-112
No files matched your search
+51
-18
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user