From 0fc3766da766c1ee375f21d4263cdf14f2c61b0e Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 8 Apr 2026 00:16:44 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20SPLINEDIT=20=E2=80=94=20close,=20open,?= =?UTF-8?q?=20reverse=20spline=20control-point=20editing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - splinedit.rs: SplineditCommand picks a spline (entity-pick phase) then accepts CLOSE/OPEN/REVERSE/EXIT sub-commands via text input - apply_spline_op() mutates the spline in the document directly, with knot vector regeneration via generate_clamped_knots() - Sentinel XLine trick: ReplaceEntity with a magic __SPLINEDIT_* layer name is intercepted in cmd_result.rs before erasing the original entity - Control-point dragging already works via the existing grip editing system - ROADMAP.md: SPLINEDIT marked ✅ Co-Authored-By: Claude Sonnet 4.6 --- ROADMAP.md | 2 +- src/app/cmd_result.rs | 19 +++ src/app/commands.rs | 7 + src/modules/home/modify/mod.rs | 1 + src/modules/home/modify/splinedit.rs | 187 +++++++++++++++++++++++++++ 5 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 src/modules/home/modify/splinedit.rs diff --git a/ROADMAP.md b/ROADMAP.md index 1578fd5a6..e493c9ca0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -125,7 +125,7 @@ Underlay (PDF/DWF/DGN) | EXPLODE (X) | ✅ | — | | PEDIT (PE) | ✅ | — | | STRETCH (SS) | ✅ | — | -| SPLINEDIT | ⬜ | — | +| SPLINEDIT | ✅ CLOSE/OPEN/REVERSE + grip editing | — | | HATCHEDIT | ✅ | — | | ATTEDIT | ⬜ | Attribute değerlerini düzenleme | | DDEDIT (çift tık metin) | ✅ | — | diff --git a/src/app/cmd_result.rs b/src/app/cmd_result.rs index ea8133a54..da64fcdc6 100644 --- a/src/app/cmd_result.rs +++ b/src/app/cmd_result.rs @@ -170,6 +170,25 @@ impl H7CAD { self.refresh_properties(); } CmdResult::ReplaceEntity(handle, new_entities) => { + // Detect SPLINEDIT sentinel: a single XLine with a magic layer name. + if new_entities.len() == 1 { + if let acadrust::EntityType::XLine(ref xl) = new_entities[0] { + let op = xl.common.layer.clone(); + if op.starts_with("__SPLINEDIT_") { + let label = self.history_label_from_active_cmd(i, "SPLINEDIT"); + self.push_undo_snapshot(i, label); + crate::modules::home::modify::splinedit::apply_spline_op( + &mut self.tabs[i].scene.document, + handle, + &op, + ); + self.tabs[i].dirty = true; + let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); + if let Some(p) = prompt { self.command_line.push_info(&p); } + return Task::none(); + } + } + } let label = self.history_label_from_active_cmd(i, "TRIM"); self.push_undo_snapshot(i, label); self.tabs[i].scene.erase_entities(&[handle]); diff --git a/src/app/commands.rs b/src/app/commands.rs index b4fe6ecca..57cecc3c9 100644 --- a/src/app/commands.rs +++ b/src/app/commands.rs @@ -1435,6 +1435,13 @@ impl H7CAD { self.tabs[i].active_cmd = Some(Box::new(cmd_obj)); } + "SPLINEDIT"|"SPE" => { + use crate::modules::home::modify::splinedit::SplineditCommand; + let cmd_obj = SplineditCommand::new(); + self.command_line.push_info(&cmd_obj.prompt()); + self.tabs[i].active_cmd = Some(Box::new(cmd_obj)); + } + "ALIGN"|"AL" => { use crate::modules::home::modify::align::AlignCommand; let cmd = AlignCommand::new(); diff --git a/src/modules/home/modify/mod.rs b/src/modules/home/modify/mod.rs index 93c8e751d..f3cdd0c23 100644 --- a/src/modules/home/modify/mod.rs +++ b/src/modules/home/modify/mod.rs @@ -16,4 +16,5 @@ pub mod scale; pub mod stretch; pub mod translate; pub mod spline_ops; +pub mod splinedit; pub mod trim; diff --git a/src/modules/home/modify/splinedit.rs b/src/modules/home/modify/splinedit.rs new file mode 100644 index 000000000..d3d5a29bd --- /dev/null +++ b/src/modules/home/modify/splinedit.rs @@ -0,0 +1,187 @@ +// SPLINEDIT command — interactive spline editing. +// +// Phase 1: select a spline (entity pick) +// Phase 2: choose a sub-command: +// CLOSE — set the spline as closed (wrap last control point to first) +// OPEN — remove the closure +// REVERSE— reverse the control point order +// EXIT — done (Enter / Escape) +// +// Control-point dragging is already supported via the grip editing system. + +use acadrust::EntityType; +use glam::Vec3; + +use crate::command::{CadCommand, CmdResult}; +use crate::modules::{IconKind, ModuleEvent, ToolDef}; +use crate::scene::wire_model::WireModel; + +#[allow(dead_code)] +pub fn tool() -> ToolDef { + ToolDef { + id: "SPLINEDIT", + label: "Spline Edit", + icon: IconKind::Svg(include_bytes!("../../../../assets/icons/spline.svg")), + event: ModuleEvent::Command("SPLINEDIT".to_string()), + } +} + +enum Step { + /// Waiting for the user to pick a spline entity. + SelectSpline, + /// Spline selected; waiting for sub-command text input. + SubCommand { handle: acadrust::Handle }, +} + +pub struct SplineditCommand { + step: Step, +} + +impl SplineditCommand { + pub fn new() -> Self { + Self { step: Step::SelectSpline } + } +} + +impl CadCommand for SplineditCommand { + fn name(&self) -> &'static str { "SPLINEDIT" } + + fn prompt(&self) -> String { + match &self.step { + Step::SelectSpline => "SPLINEDIT Select spline:".to_string(), + Step::SubCommand { .. } => { + "SPLINEDIT [CLOSE/OPEN/REVERSE/EXIT]:".to_string() + } + } + } + + fn needs_entity_pick(&self) -> bool { + matches!(self.step, Step::SelectSpline) + } + + fn on_entity_pick(&mut self, handle: acadrust::Handle, _pt: Vec3) -> CmdResult { + if handle.is_null() { + return CmdResult::NeedPoint; + } + self.step = Step::SubCommand { handle }; + CmdResult::NeedPoint + } + + fn wants_text_input(&self) -> bool { + matches!(self.step, Step::SubCommand { .. }) + } + + fn on_text_input(&mut self, text: &str) -> Option { + let Step::SubCommand { handle } = &self.step else { return None; }; + let handle = *handle; + match text.trim().to_uppercase().as_str() { + "CLOSE" | "C" => Some(CmdResult::ReplaceEntity( + handle, + vec![SplineOp::Close.into_marker(handle)], + )), + "OPEN" | "O" => Some(CmdResult::ReplaceEntity( + handle, + vec![SplineOp::Open.into_marker(handle)], + )), + "REVERSE" | "R" | "REV" => Some(CmdResult::ReplaceEntity( + handle, + vec![SplineOp::Reverse.into_marker(handle)], + )), + "EXIT" | "X" | "" => Some(CmdResult::Cancel), + _ => None, + } + } + + fn on_point(&mut self, _pt: Vec3) -> CmdResult { CmdResult::NeedPoint } + fn on_enter(&mut self) -> CmdResult { CmdResult::Cancel } + fn on_preview_wires(&mut self, _pt: Vec3) -> Vec { vec![] } +} + +// ── Spline operation helpers ─────────────────────────────────────────────── +// +// Because `CmdResult::ReplaceEntity` takes `Vec` and we need the +// host to apply it to the actual document, we use a small "deferred op" trick: +// the host applies the spline transform in `apply_cmd_result` by detecting +// that we replaced an entity with zero new entities (delete) or one new one. +// +// Here we actually build the modified entity without accessing the document — +// we only have the handle. The real transformation (close/open/reverse) is +// applied in the Scene via `apply_spline_op`. + +enum SplineOp { Close, Open, Reverse } + +impl SplineOp { + /// Return a placeholder that encodes the op in a comment field. + /// The host `cmd_result.rs` detects SPLINEDIT replaces and calls + /// `Scene::apply_spline_op`. + fn into_marker(self, _handle: acadrust::Handle) -> EntityType { + // We can't build the modified spline here without the document. + // Return a sentinel — the actual transformation is handled by the + // apply_spline_op path in Scene. We use an XLine as a sentinel + // that the host will never actually commit (it detects the SPLINEDIT + // replace path and calls apply_spline_op instead). + // + // In practice: use ReplaceMany with empty new entities + a SplineOp + // side-channel via the xattach_path mechanism isn't clean. + // + // Simpler: encode op in the sentinel entity's layer field. + let mut sentinel = acadrust::entities::XLine::new( + acadrust::types::Vector3::zero(), + acadrust::types::Vector3::new(1.0, 0.0, 0.0), + ); + sentinel.common.layer = match self { + SplineOp::Close => "__SPLINEDIT_CLOSE__".to_string(), + SplineOp::Open => "__SPLINEDIT_OPEN__".to_string(), + SplineOp::Reverse => "__SPLINEDIT_REVERSE__".to_string(), + }; + EntityType::XLine(sentinel) + } +} + +/// Apply a spline operation (CLOSE/OPEN/REVERSE) to a spline entity. +/// Called from `cmd_result.rs` when the ReplaceEntity sentinel is detected. +pub fn apply_spline_op(doc: &mut acadrust::CadDocument, handle: acadrust::Handle, op: &str) { + + let Some(EntityType::Spline(spline)) = doc.get_entity_mut(handle) else { return; }; + match op { + "__SPLINEDIT_CLOSE__" => { + if spline.control_points.len() >= 2 { + let first = spline.control_points[0]; + spline.control_points.push(first); + // Regenerate clamped knots. + spline.knots = acadrust::entities::Spline::generate_clamped_knots( + spline.degree as usize, + spline.control_points.len(), + ); + spline.fit_points.clear(); + } + } + "__SPLINEDIT_OPEN__" => { + if spline.control_points.len() >= 2 { + let n = spline.control_points.len(); + let first = spline.control_points[0]; + let last = spline.control_points[n - 1]; + if (first.x - last.x).abs() < 1e-9 + && (first.y - last.y).abs() < 1e-9 + && (first.z - last.z).abs() < 1e-9 + { + spline.control_points.pop(); + spline.knots = acadrust::entities::Spline::generate_clamped_knots( + spline.degree as usize, + spline.control_points.len(), + ); + spline.fit_points.clear(); + } + } + } + "__SPLINEDIT_REVERSE__" => { + spline.control_points.reverse(); + spline.fit_points.reverse(); + spline.knots = acadrust::entities::Spline::generate_clamped_knots( + spline.degree as usize, + spline.control_points.len(), + ); + } + _ => {} + } +}