feat(pline): create polyline as a live entity so it snaps while drawing

The polyline was only committed to the document on finish, so its partial
geometry produced no snap points — you could not snap to vertices already
placed. Publish it as a real LwPolyline at the second vertex and replace
its geometry in place as later vertices land, so it lives in the document
(and the snapper) throughout the draw.

New CmdResult::CommitLiveEntity / UpdateLiveEntity{finish} plus a
set_live_handle() hook let a command own an entity mid-run. The create
pushes a single undo snapshot; in-place updates push none, so the whole
object reverts as one unit. Updates preserve the entity's handle and
layer (a fresh built entity carries a NULL handle that would desync it
from the document map key and drop it from rendering). (#119)

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-06-18 21:02:50 +03:00
1 parent cdd72c62ca
commit 188d310789
3 files changed
+115 -40

No files matched your search

+50
View File
@@ -460,6 +460,56 @@ impl OpenCADStudio {
}
}
}
CmdResult::CommitLiveEntity(entity) => {
let label = self.history_label_from_active_cmd(i, "ENTITY");
self.push_undo_snapshot(i, label);
let handle = self.commit_entity_handle(entity);
self.tabs[i].dirty = true;
self.tabs[i].scene.clear_preview_wire();
if let Some(h) = handle {
if let Some(cmd) = self.tabs[i].active_cmd.as_mut() {
cmd.set_live_handle(h);
}
}
let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt());
if let Some(p) = prompt {
self.command_line.push_info(&p);
}
}
CmdResult::UpdateLiveEntity {
handle,
entity,
finish,
} => {
// Replace the live entity's geometry in place, preserving its
// handle and layer (the fresh entity from the command carries
// defaults — a NULL handle would desync it from the document
// map key and drop it from rendering / hit-test). No undo
// snapshot — the create already pushed one, so the whole object
// reverts as a unit.
if let Some(old) = self.tabs[i].scene.document.get_entity_mut(handle) {
let old_handle = old.as_entity().handle();
let layer = old.as_entity().layer().to_string();
let mut new = entity;
new.as_entity_mut().set_handle(old_handle);
new.as_entity_mut().set_layer(layer);
*old = new;
self.tabs[i].scene.mark_entity_dirty(handle);
self.tabs[i].scene.bump_geometry_no_blocks();
self.tabs[i].dirty = true;
}
if finish {
self.tabs[i].scene.clear_preview_wire();
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.restore_pre_cmd_tangent();
} else {
let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt());
if let Some(p) = prompt {
self.command_line.push_info(&p);
}
}
}
CmdResult::Cancel => {
self.tabs[i].scene.clear_preview_wire();
self.tabs[i].active_cmd = None;
+20
View File
@@ -225,6 +225,21 @@ pub enum CmdResult {
/// The host should look up the attdefs for `block_name` from the document
/// and call `attreq_set_attdefs()` on the command, then loop on text input.
AttreqNeeded { block_name: String },
/// Add a command-owned "live" entity to the document mid-command and hand
/// its assigned handle back to the active command via `set_live_handle()`.
/// One undo snapshot is pushed here, so the whole in-progress object reverts
/// as a single unit. The command stays active. Used by PLINE so the partial
/// polyline is a real, snappable entity while later vertices are placed.
CommitLiveEntity(EntityType),
/// Replace the geometry of the live entity `handle` in place — preserving
/// its layer — without pushing a new undo snapshot. When `finish` is true
/// the command also exits (the entity is already committed, so no separate
/// commit is needed).
UpdateLiveEntity {
handle: Handle,
entity: EntityType,
finish: bool,
},
}
/// What kind of value the active command is currently asking for. Drives
@@ -417,6 +432,11 @@ pub trait CadCommand: Send {
CmdResult::Cancel
}
/// Host callback after `CmdResult::CommitLiveEntity`: records the handle the
/// new live entity was assigned so later `UpdateLiveEntity` results can
/// target it.
fn set_live_handle(&mut self, _handle: Handle) {}
/// Point-click pick of domain objects (wire hit-test often misses small markers).
fn needs_structure_point_pick(&self) -> bool {
false
+45 -40
View File
@@ -11,7 +11,7 @@
use acadrust::entities::LwVertex;
use acadrust::types::Vector2;
use acadrust::{EntityType, LwPolyline};
use acadrust::{EntityType, Handle, LwPolyline};
use glam::{Vec2, Vec3};
use crate::command::{CadCommand, CmdResult};
@@ -46,6 +46,11 @@ pub struct PlineCommand {
mode: SegMode,
/// Unit direction of the last committed segment (for arc tangent continuity).
last_tangent: Option<Vec2>,
/// Handle of the live polyline entity once it exists (created at the 2nd
/// vertex). `None` while only the start point is placed. Having it in the
/// document makes the partial polyline snappable while later vertices are
/// being placed. (#119)
live_handle: Option<Handle>,
}
impl PlineCommand {
@@ -55,6 +60,24 @@ impl PlineCommand {
bulges: Vec::new(),
mode: SegMode::Line,
last_tangent: None,
live_handle: None,
}
}
/// Build the result that publishes the current vertices to the document:
/// create the live entity at the 2nd vertex, then replace it in place as
/// more vertices land. `finish` ends the command (used for close/done).
fn sync_live(&self, closed: bool, finish: bool) -> CmdResult {
let entity = self.build_entity(closed);
match (entity, self.live_handle) {
(Some(e), Some(handle)) => CmdResult::UpdateLiveEntity {
handle,
entity: e,
finish,
},
(Some(e), None) => CmdResult::CommitLiveEntity(e),
// Fewer than 2 vertices: nothing to publish.
(None, _) => CmdResult::Cancel,
}
}
@@ -231,21 +254,26 @@ impl CadCommand for PlineCommand {
self.vertices.push(pt);
self.bulges.push(0.0);
CmdResult::NeedPoint
// Publish to the document as soon as the polyline has a segment so it
// becomes snappable while the rest is drawn. (#119)
if self.vertices.len() >= 2 {
self.sync_live(false, false)
} else {
CmdResult::NeedPoint
}
}
fn set_live_handle(&mut self, handle: Handle) {
self.live_handle = Some(handle);
}
fn on_enter(&mut self) -> CmdResult {
match self.build_entity(false) {
Some(e) => CmdResult::CommitAndExit(e),
None => CmdResult::Cancel,
}
// The committed vertices already live in the document; finalise it.
self.sync_live(false, true)
}
fn on_escape(&mut self) -> CmdResult {
match self.build_entity(false) {
Some(e) => CmdResult::CommitAndExit(e),
None => CmdResult::Cancel,
}
self.sync_live(false, true)
}
fn wants_text_input(&self) -> bool {
@@ -269,44 +297,21 @@ impl CadCommand for PlineCommand {
self.mode = SegMode::Line;
Some(CmdResult::NeedPoint)
}
"C" | "CLOSE" => match self.build_entity(true) {
Some(e) => Some(CmdResult::CommitAndExit(e)),
None => Some(CmdResult::Cancel),
},
"C" | "CLOSE" => Some(self.sync_live(true, true)),
_ => None,
}
}
fn on_mouse_move(&mut self, pt: Vec3) -> Option<WireModel> {
if self.vertices.is_empty() {
return None;
}
let last = *self.vertices.last().unwrap();
// The committed vertices already render as a real document entity, so
// the preview is just the pending segment from the last vertex to the
// cursor. (#119)
let last = *self.vertices.last()?;
// Build committed segments first.
let mut pts: Vec<[f32; 3]> = Vec::new();
// Re-emit all committed vertices + arc samples between them.
for i in 0..self.vertices.len() {
let v = self.vertices[i];
if i == 0 {
pts.push([v.x, v.y, v.z]);
} else {
let prev = self.vertices[i - 1];
let b = self.bulges.get(i - 1).copied().unwrap_or(0.0);
if b.abs() > 1e-6 {
// Arc segment: sample it.
let arc_pts = arc_sample_points(prev, b, v, 16);
pts.extend_from_slice(&arc_pts[1..]); // skip first (already added)
} else {
pts.push([v.x, v.y, v.z]);
}
}
}
// Rubber-band to cursor.
match self.mode {
SegMode::Line => {
pts.push([last.x, last.y, last.z]);
pts.push([pt.x, pt.y, pt.z]);
}
SegMode::Arc => {
@@ -315,7 +320,7 @@ impl CadCommand for PlineCommand {
let tangent = self.last_tangent.unwrap_or(Vec2::new(1.0, 0.0));
let bulge = compute_bulge(a, tangent, b);
let arc_pts = arc_sample_points(last, bulge, pt, 16);
pts.extend_from_slice(&arc_pts[1..]);
pts.extend_from_slice(&arc_pts);
}
}