feat: SPLINEDIT — close, open, reverse spline control-point editing

- 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 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-04-08 00:16:44 +03:00
commit 0fc3766da7
5 changed files with 215 additions and 1 deletions

View file

@ -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) | ✅ | — |

View file

@ -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]);

View file

@ -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();

View file

@ -16,4 +16,5 @@ pub mod scale;
pub mod stretch;
pub mod translate;
pub mod spline_ops;
pub mod splinedit;
pub mod trim;

View file

@ -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<CmdResult> {
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<WireModel> { vec![] }
}
// ── Spline operation helpers ───────────────────────────────────────────────
//
// Because `CmdResult::ReplaceEntity` takes `Vec<EntityType>` 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(),
);
}
_ => {}
}
}