From a773a1a1f1de35e1f6bc06b2589821e19c2fd481 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 8 Apr 2026 12:40:19 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20REFEDIT=20/=20REFCLOSE=20=E2=80=94=20in?= =?UTF-8?q?-place=20block=20reference=20editing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit REFEDIT picks an INSERT, extracts the block entities into model space with the INSERT transform applied (translate + rotate + uniform scale), and enters an editing session. Any normal modify command works on the temporary entities. REFCLOSE SAVE applies the inverse transform, replaces the block definition, and rebuilds all derived caches (hatch/image/mesh), so every INSERT of that block reflects the edits immediately. REFCLOSE DISCARD removes the temp entities without changing the block. Non-uniform-scale inserts are rejected with an error message. RefEditSession is stored on DocumentTab; undo snapshots bracket the begin and close operations. Co-Authored-By: Claude Sonnet 4.6 --- ROADMAP.md | 4 +- src/app/commands.rs | 217 +++++++++++++++++++++++++++++ src/app/document.rs | 4 + src/modules/home/modify/mod.rs | 1 + src/modules/home/modify/refedit.rs | 210 ++++++++++++++++++++++++++++ src/scene/mod.rs | 8 ++ 6 files changed, 442 insertions(+), 2 deletions(-) create mode 100644 src/modules/home/modify/refedit.rs diff --git a/ROADMAP.md b/ROADMAP.md index 152061f6..e3fd5747 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,7 +129,7 @@ Underlay (PDF/DWF/DGN) | HATCHEDIT | ✅ | — | | ATTEDIT | ✅ Interactive tag-by-tag value editing | — | | DDEDIT (çift tık metin) | ✅ | — | -| REFEDIT | ⬜ | Block in-place düzenleme | +| REFEDIT | ✅ | Block in-place düzenleme | | DIVIDE (DIV) | ✅ | — | | MEASURE (ME) | ✅ | — | @@ -225,7 +225,7 @@ Underlay (PDF/DWF/DGN) | 3D Sphere primitive | ✅ | | 3D Cylinder primitive | ✅ | | OBJ dosyası içe aktarma | ✅ | -| REFEDIT (block yerinde düzenleme) | ⬜ | +| REFEDIT (block yerinde düzenleme) | ✅ | | WBLOCK (bloğu dış dosyaya yaz) | ✅ | | Attributeli INSERT akışı (ATTREQ) | ✅ | diff --git a/src/app/commands.rs b/src/app/commands.rs index d1745c40..b8fa4d5a 100644 --- a/src/app/commands.rs +++ b/src/app/commands.rs @@ -1472,6 +1472,223 @@ impl H7CAD { self.tabs[i].active_cmd = Some(Box::new(cmd_obj)); } + // ── REFEDIT — in-place block editing ───────────────────────────── + "REFEDIT" => { + use crate::modules::home::modify::refedit::RefEditPickCommand; + // If a session is already active, tell the user. + if self.tabs[i].refedit_session.is_some() { + self.command_line.push_error( + "REFEDIT: a session is already active. Use REFCLOSE first." + ); + } else { + // Check if a single INSERT is already selected. + let selected: Vec<_> = self.tabs[i].scene.selected_entities().into_iter().collect(); + if selected.len() == 1 { + if let Some(acadrust::EntityType::Insert(_)) = selected.first().map(|(_, e)| e) { + let handle = selected[0].0; + // Skip pick phase — jump straight to begin. + let _ = self.dispatch_command(&format!("REFEDIT_BEGIN:{}", handle.value())); + return Task::none(); + } + } + let cmd_obj = RefEditPickCommand::new(); + self.command_line.push_info(&cmd_obj.prompt()); + self.tabs[i].active_cmd = Some(Box::new(cmd_obj)); + } + } + + cmd if cmd.starts_with("REFEDIT_BEGIN:") => { + use crate::modules::home::modify::refedit::{RefEditSession, apply_insert_transform}; + use acadrust::Handle; + + let handle_u64: u64 = cmd["REFEDIT_BEGIN:".len()..] + .parse().unwrap_or(0); + let insert_handle = Handle::new(handle_u64); + + // Get INSERT entity. + let insert = match self.tabs[i].scene.document.get_entity(insert_handle) { + Some(acadrust::EntityType::Insert(ins)) => ins.clone(), + _ => { + self.command_line.push_error("REFEDIT: selected object is not an INSERT."); + return Task::none(); + } + }; + + // Validate: non-uniform scale is not supported. + let sx = insert.x_scale(); + let sy = insert.y_scale(); + let sz = insert.z_scale(); + if (sx - sy).abs() > 1e-6 || (sx - sz).abs() > 1e-6 { + self.command_line.push_error( + "REFEDIT: non-uniform scale inserts are not supported." + ); + return Task::none(); + } + + // Find the block record. + let br_handle = match self.tabs[i].scene.document.block_records.get(&insert.block_name) { + Some(br) => br.handle, + None => { + self.command_line.push_error(&format!( + "REFEDIT: block \"{}\" not found.", insert.block_name + )); + return Task::none(); + } + }; + + // Collect block-local entities (skip structural Block/BlockEnd/AttDef). + let block_entities: Vec<_> = { + let br = self.tabs[i].scene.document.block_records.get(&insert.block_name).unwrap(); + br.entity_handles + .iter() + .filter_map(|h| self.tabs[i].scene.document.get_entity(*h).cloned()) + .filter(|e| !matches!(e, + acadrust::EntityType::Block(_) | + acadrust::EntityType::BlockEnd(_) | + acadrust::EntityType::AttributeDefinition(_) + )) + .collect() + }; + + if block_entities.is_empty() { + self.command_line.push_error("REFEDIT: block is empty."); + return Task::none(); + } + + let session = RefEditSession { + insert_handle, + block_name: insert.block_name.clone(), + br_handle, + temp_handles: vec![], + insert_x: insert.insert_point.x, + insert_y: insert.insert_point.y, + insert_z: insert.insert_point.z, + rotation_deg: insert.rotation.to_degrees(), + scale: sx, + }; + + self.push_undo_snapshot(i, "REFEDIT"); + self.tabs[i].refedit_session = Some(session.clone()); + + // Add block entities to model space with INSERT transform applied. + let mut temp_handles = Vec::new(); + for mut entity in block_entities { + apply_insert_transform(&mut entity, &session); + entity.common_mut().handle = acadrust::Handle::NULL; + entity.common_mut().owner_handle = acadrust::Handle::NULL; + let h = self.tabs[i].scene.add_entity(entity); + temp_handles.push(h); + } + self.tabs[i].refedit_session.as_mut().unwrap().temp_handles = temp_handles.clone(); + + // Select the temp entities so user can see what they're editing. + self.tabs[i].scene.deselect_all(); + for h in &temp_handles { + self.tabs[i].scene.select_entity(*h, false); + } + self.tabs[i].dirty = true; + + self.command_line.push_info(&format!( + "REFEDIT: Editing block \"{}\". Use REFCLOSE when done.", + insert.block_name + )); + use crate::modules::home::modify::refedit::RefCloseCommand; + let cmd_obj = RefCloseCommand::new(); + self.command_line.push_info(&cmd_obj.prompt()); + self.tabs[i].active_cmd = Some(Box::new(cmd_obj)); + } + + "REFCLOSE" => { + if self.tabs[i].refedit_session.is_some() { + use crate::modules::home::modify::refedit::RefCloseCommand; + let cmd_obj = RefCloseCommand::new(); + self.command_line.push_info(&cmd_obj.prompt()); + self.tabs[i].active_cmd = Some(Box::new(cmd_obj)); + } else { + self.command_line.push_error("REFCLOSE: no REFEDIT session active."); + } + } + + "REFCLOSE_SAVE" => { + use crate::modules::home::modify::refedit::apply_insert_inverse_transform; + use crate::modules::home::modify::explode::normalize_entity_for_block; + + let session = match self.tabs[i].refedit_session.take() { + Some(s) => s, + None => { + self.command_line.push_error("REFCLOSE: no REFEDIT session active."); + return Task::none(); + } + }; + + self.push_undo_snapshot(i, "REFCLOSE"); + + // Collect the edited temp entities. + let mut new_entities: Vec = session.temp_handles + .iter() + .filter_map(|h| self.tabs[i].scene.document.get_entity(*h).cloned()) + .collect(); + + // Remove temp entities from model space. + self.tabs[i].scene.erase_entities(&session.temp_handles); + + // Apply inverse INSERT transform → block-local coordinates. + let new_entities: Vec<_> = new_entities + .into_iter() + .map(|mut entity| { + apply_insert_inverse_transform(&mut entity, &session); + let mut entity = normalize_entity_for_block(entity); + entity.common_mut().handle = acadrust::Handle::NULL; + entity.common_mut().owner_handle = session.br_handle; + entity + }) + .collect(); + + // Remove old block entities from the document. + let old_handles: Vec<_> = match self.tabs[i].scene.document + .block_records.get(&session.block_name) + { + Some(br) => br.entity_handles.clone(), + None => vec![], + }; + for h in &old_handles { + self.tabs[i].scene.document.remove_entity(*h); + } + // Flush the entity_handles list from the block record. + if let Some(br) = self.tabs[i].scene.document + .block_records.get_mut(&session.block_name) + { + br.entity_handles.clear(); + } + + // Add the new block entities. + for entity in new_entities { + let _ = self.tabs[i].scene.document.add_entity(entity); + } + + self.tabs[i].dirty = true; + self.command_line.push_output(&format!( + "REFCLOSE: Block \"{}\" saved. All references updated.", + session.block_name + )); + // Rebuild hatch/image/mesh caches since block content changed. + self.tabs[i].scene.rebuild_derived_caches(); + } + + "REFCLOSE_DISCARD" => { + let session = match self.tabs[i].refedit_session.take() { + Some(s) => s, + None => { + self.command_line.push_error("REFCLOSE: no REFEDIT session active."); + return Task::none(); + } + }; + // Remove temp entities without modifying the block. + self.tabs[i].scene.erase_entities(&session.temp_handles); + self.tabs[i].scene.deselect_all(); + self.command_line.push_output("REFCLOSE: Changes discarded."); + } + "ALIGN"|"AL" => { use crate::modules::home::modify::align::AlignCommand; let cmd = AlignCommand::new(); diff --git a/src/app/document.rs b/src/app/document.rs index 8f7a985d..78dd8886 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -4,6 +4,7 @@ use crate::command::CadCommand; use crate::snap::SnapResult; use crate::scene::grip::GripEdit; use crate::scene::GripDef; +use crate::modules::home::modify::refedit::RefEditSession; use acadrust::{CadDocument, Handle}; use acadrust::tables::Ucs; use crate::linetypes; @@ -37,6 +38,8 @@ pub(super) struct DocumentTab { pub(super) bg_color: Option<[f32; 4]>, /// Custom paper-space background color. `None` = default off-white grey. pub(super) paper_bg_color: Option<[f32; 4]>, + /// Active REFEDIT session, if any. + pub(super) refedit_session: Option, } impl DocumentTab { @@ -65,6 +68,7 @@ impl DocumentTab { active_ucs: None, bg_color: None, paper_bg_color: None, + refedit_session: None, } } diff --git a/src/modules/home/modify/mod.rs b/src/modules/home/modify/mod.rs index fe0ef897..9613a813 100644 --- a/src/modules/home/modify/mod.rs +++ b/src/modules/home/modify/mod.rs @@ -18,4 +18,5 @@ pub mod stretch; pub mod translate; pub mod spline_ops; pub mod splinedit; +pub mod refedit; pub mod trim; diff --git a/src/modules/home/modify/refedit.rs b/src/modules/home/modify/refedit.rs new file mode 100644 index 00000000..c3c8e218 --- /dev/null +++ b/src/modules/home/modify/refedit.rs @@ -0,0 +1,210 @@ +// REFEDIT — in-place block reference editing. +// +// Workflow: +// 1. REFEDIT: user picks an INSERT entity. +// 2. The block's entities are copied into model space with the INSERT +// transform applied (translate + rotate + uniform scale). +// 3. The user edits them with normal commands (MOVE, COPY, DELETE, …). +// 4. REFCLOSE SAVE: temp entities are inverse-transformed back into the +// block definition; all INSERT references auto-update. +// REFCLOSE DISCARD: temp entities are removed, block unchanged. +// +// Limitation: non-uniform scale inserts (x_scale ≠ y_scale) are rejected +// with an error message — full matrix inversion for those cases would +// require per-entity matrix transforms not yet in EntityTransform. + +use acadrust::{EntityType, Handle}; +use glam::Vec3; + +use crate::command::{CadCommand, CmdResult}; + +// ── Session state (held in DocumentTab) ─────────────────────────────────── + +/// Active REFEDIT session. Lives in `DocumentTab::refedit_session`. +#[derive(Debug, Clone)] +pub struct RefEditSession { + /// The INSERT entity being edited. + pub insert_handle: Handle, + /// Name of the block being edited. + pub block_name: String, + /// Handle of the block record (owns the block entities). + pub br_handle: Handle, + /// Handles of the temporary model-space entities added for editing. + pub temp_handles: Vec, + // ── INSERT transform (needed for inverse on SAVE) ────────────────── + pub insert_x: f64, + pub insert_y: f64, + pub insert_z: f64, + /// Rotation in degrees (stored as degrees in acadrust). + pub rotation_deg: f64, + /// Uniform scale factor (same for X/Y/Z after validation). + pub scale: f64, +} + +// ── REFEDIT pick command ─────────────────────────────────────────────────── + +/// Step 1: wait for the user to pick a single INSERT entity. +pub struct RefEditPickCommand; + +impl RefEditPickCommand { + pub fn new() -> Self { + Self + } +} + +impl CadCommand for RefEditPickCommand { + fn name(&self) -> &'static str { + "REFEDIT" + } + fn prompt(&self) -> String { + "REFEDIT Select block reference to edit:".into() + } + + fn needs_entity_pick(&self) -> bool { + true + } + + fn on_entity_pick(&mut self, handle: Handle, _pt: Vec3) -> CmdResult { + if handle.is_null() { + return CmdResult::NeedPoint; + } + // Signal the host to enter the editing session for this handle. + // We reuse Relaunch("REFEDIT_BEGIN:") as a convention. + CmdResult::Relaunch( + format!("REFEDIT_BEGIN:{}", handle.value()), + vec![handle], + ) + } + + fn on_point(&mut self, _pt: Vec3) -> CmdResult { + CmdResult::NeedPoint + } + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel + } +} + +// ── REFCLOSE command ─────────────────────────────────────────────────────── + +/// Step 4: prompt for SAVE or DISCARD. +pub struct RefCloseCommand; + +impl RefCloseCommand { + pub fn new() -> Self { + Self + } +} + +impl CadCommand for RefCloseCommand { + fn name(&self) -> &'static str { + "REFCLOSE" + } + fn prompt(&self) -> String { + "REFCLOSE [Save/Discard] :".into() + } + fn wants_text_input(&self) -> bool { + true + } + fn on_text_input(&mut self, text: &str) -> Option { + let t = text.trim().to_uppercase(); + let save = t.is_empty() || t == "S" || t == "SAVE"; + let discard = t == "D" || t == "DISCARD"; + if save { + Some(CmdResult::Relaunch("REFCLOSE_SAVE".into(), vec![])) + } else if discard { + Some(CmdResult::Relaunch("REFCLOSE_DISCARD".into(), vec![])) + } else { + Some(CmdResult::NeedPoint) // re-prompt + } + } + fn on_enter(&mut self) -> CmdResult { + // Default: SAVE + CmdResult::Relaunch("REFCLOSE_SAVE".into(), vec![]) + } + fn on_point(&mut self, _pt: Vec3) -> CmdResult { + CmdResult::NeedPoint + } +} + +// ── Geometry helpers ─────────────────────────────────────────────────────── + +/// Apply the INSERT's forward transform to a block-local entity so it +/// appears at its correct world-space position. +/// Order: scale → rotate (around origin) → translate. +pub fn apply_insert_transform(entity: &mut EntityType, session: &RefEditSession) { + use crate::command::EntityTransform; + use crate::scene::dispatch; + + let origin = Vec3::ZERO; + + // 1. Uniform scale (if not 1.0) + if (session.scale - 1.0).abs() > 1e-10 { + dispatch::apply_transform( + entity, + &EntityTransform::Scale { center: origin, factor: session.scale as f32 }, + ); + } + + // 2. Rotate around origin + if session.rotation_deg.abs() > 1e-10 { + dispatch::apply_transform( + entity, + &EntityTransform::Rotate { + center: origin, + angle_rad: session.rotation_deg.to_radians() as f32, + }, + ); + } + + // 3. Translate to insert position + dispatch::apply_transform( + entity, + &EntityTransform::Translate(Vec3::new( + session.insert_x as f32, + session.insert_y as f32, + session.insert_z as f32, + )), + ); +} + +/// Apply the INSERT's inverse transform to a world-space entity to bring it +/// back to block-local coordinates. +/// Order: un-translate → un-rotate → un-scale. +pub fn apply_insert_inverse_transform(entity: &mut EntityType, session: &RefEditSession) { + use crate::command::EntityTransform; + use crate::scene::dispatch; + + let origin = Vec3::ZERO; + + // 1. Un-translate + dispatch::apply_transform( + entity, + &EntityTransform::Translate(Vec3::new( + -session.insert_x as f32, + -session.insert_y as f32, + -session.insert_z as f32, + )), + ); + + // 2. Un-rotate + if session.rotation_deg.abs() > 1e-10 { + dispatch::apply_transform( + entity, + &EntityTransform::Rotate { + center: origin, + angle_rad: -session.rotation_deg.to_radians() as f32, + }, + ); + } + + // 3. Un-scale + if (session.scale - 1.0).abs() > 1e-10 && session.scale.abs() > 1e-12 { + dispatch::apply_transform( + entity, + &EntityTransform::Scale { + center: origin, + factor: (1.0 / session.scale) as f32, + }, + ); + } +} diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 968e0bba..ef1d4e84 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1600,6 +1600,14 @@ impl Scene { } } + /// Rebuild hatch / image / mesh caches after the document is modified + /// outside the normal `add_entity` path (e.g. REFCLOSE SAVE). + pub fn rebuild_derived_caches(&mut self) { + self.populate_hatches_from_document(); + self.populate_images_from_document(); + self.populate_meshes_from_document(); + } + /// Build a solid-fill HatchModel for a DXF Solid entity. /// DXF SOLID corners are in "Z-order": p0-p1 top, p2-p3 bottom. /// Visual quad is p0→p1→p3→p2 (closed).