diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index af8dc7202..c0a15929d 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -6456,3 +6456,35 @@ view-background = .help = Sets the background a named view shows when it is restored. .image-files = Image files .select-image = Select Background Image + +ref-edit = + .select-reference = Select reference: + .reference-not-found = Reference not found. + .not-valid-reference = Object is not a valid reference + .one-entity-added = 1 entity added + .nesting-level = Select nesting level [Ok/Next] : + .selection-method = Enter object selection method [All/Nested] : + .select-nested = Select nested objects: + .display-attdefs = Display attribute definitions [Yes/No] : + .zero-items-selected = 0 items selected + .one-item-selected = 1 item selected + .items-selected = __ocs_fmt_0__ items selected + .refset-option = Enter an option [Add/Remove] : + .refclose-option = Enter option [Save/Discard reference changes] : + .not-checked-out = ** Command not allowed unless a reference is checked out with REFEDIT command ** + .already-checked-out = ** Command not allowed, __ocs_fmt_0__ already checked out for editing ** + .transfer-objects = Transfer objects between the RefEdit working set and host drawing... + .added-to-working-set = __ocs_fmt_0__ Added to working set + .removed-from-working-set = __ocs_fmt_0__ Removed from working set + .one-object-added = 1 object added to __ocs_fmt_0__ + .objects-added = __ocs_fmt_0__ objects added to __ocs_fmt_1__ + .one-object-removed = 1 object removed from __ocs_fmt_0__ + .objects-removed = __ocs_fmt_0__ objects removed from __ocs_fmt_1__ + .one-xref-instance = 1 xref instance updated + .xref-instances = __ocs_fmt_0__ xref instances updated + .one-block-instance = 1 block instance updated + .block-instances = __ocs_fmt_0__ block instances updated + .redefined-reloaded = __ocs_fmt_0__ redefined and reloaded. + .redefined = __ocs_fmt_0__ redefined. + .ok = Ok + .nested = Nested diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 41e52c8a1..c16d192e1 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -6378,3 +6378,35 @@ view-background = .help = Adlandırılmış görünüm geri yüklendiğinde gösterilecek arka planı ayarlar. .image-files = Görüntü dosyaları .select-image = Arka Plan Görüntüsü Seç + +ref-edit = + .select-reference = Referans seçin: + .reference-not-found = Referans bulunamadı. + .not-valid-reference = Nesne geçerli bir referans değil + .one-entity-added = 1 nesne eklendi + .nesting-level = İç içe düzeyi seçin [Tamam/Sonraki] : + .selection-method = Nesne seçim yöntemini girin [Tümü/İç içe] : + .select-nested = İç içe nesneleri seçin: + .display-attdefs = Öznitelik tanımları gösterilsin mi [Evet/Hayır] : + .zero-items-selected = 0 öğe seçildi + .one-item-selected = 1 öğe seçildi + .items-selected = __ocs_fmt_0__ öğe seçildi + .refset-option = Bir seçenek girin [Ekle/Kaldır] : + .refclose-option = Seçeneği girin [Kaydet/Referans değişikliklerinden vazgeç] : + .not-checked-out = ** Bir referans REFEDIT komutuyla düzenlemeye alınmadıkça komuta izin verilmez ** + .already-checked-out = ** Komuta izin verilmez, __ocs_fmt_0__ zaten düzenlemeye alınmış ** + .transfer-objects = Nesneleri RefEdit çalışma kümesi ile ana çizim arasında aktarın... + .added-to-working-set = __ocs_fmt_0__ nesne çalışma kümesine eklendi + .removed-from-working-set = __ocs_fmt_0__ nesne çalışma kümesinden çıkarıldı + .one-object-added = 1 nesne __ocs_fmt_0__ tanımına eklendi + .objects-added = __ocs_fmt_0__ nesne __ocs_fmt_1__ tanımına eklendi + .one-object-removed = 1 nesne __ocs_fmt_0__ tanımından çıkarıldı + .objects-removed = __ocs_fmt_0__ nesne __ocs_fmt_1__ tanımından çıkarıldı + .one-xref-instance = 1 xref örneği güncellendi + .xref-instances = __ocs_fmt_0__ xref örneği güncellendi + .one-block-instance = 1 blok örneği güncellendi + .block-instances = __ocs_fmt_0__ blok örneği güncellendi + .redefined-reloaded = __ocs_fmt_0__ yeniden tanımlandı ve yeniden yüklendi. + .redefined = __ocs_fmt_0__ yeniden tanımlandı. + .ok = Tamam + .nested = İç içe diff --git a/src/app/command_driver/mod.rs b/src/app/command_driver/mod.rs index 74aff053d..1109c321d 100644 --- a/src/app/command_driver/mod.rs +++ b/src/app/command_driver/mod.rs @@ -80,7 +80,7 @@ use crate::command::{CmdResult, SelectionEntity, StepInput}; use codec::Handle; use iced::Task; -mod clipboard; +pub(super) mod clipboard; mod constraint; mod dim_edit; mod entity_commit; diff --git a/src/app/commands/blockvars.rs b/src/app/commands/blockvars.rs index 8a19cde58..89046fd74 100644 --- a/src/app/commands/blockvars.rs +++ b/src/app/commands/blockvars.rs @@ -16,6 +16,9 @@ pub(super) const BLOCK_SYSVARS: &[&str] = &[ "BLOCKSTATE", "EXPLMODE", "INSNAME", + "REFEDITNAME", + "XEDIT", + "XFADECTL", ]; /// The `SETVAR ?` line for this family. @@ -45,6 +48,15 @@ impl OpenCADStudio { self.command_line .push_output(&format!("BLOCKSTATE = {state} (read only)")); } + "REFEDITNAME" => { + let name = self.tabs[i] + .refedit_session + .as_ref() + .map(|s| s.block_name.clone()) + .unwrap_or_default(); + self.command_line + .push_output(&format!("REFEDITNAME = \"{name}\" (read only)")); + } "INSNAME" | "BLOCKNAVIGATE" => { let current = if name == "INSNAME" { self.insname.clone() @@ -77,6 +89,8 @@ impl OpenCADStudio { let (current, max) = match name.as_str() { "BLOCKMRULIST" => (self.block_mru_list as i32, 100), "BLOCKREDEFINEMODE" => (self.block_redefine_mode as i32, 2), + "XEDIT" => (self.tabs[i].scene.document.header.xedit as i32, 1), + "XFADECTL" => (crate::scene::cache::block_cache::refedit_fade_ctl(), 90), _ => (explmode(&self.tabs[i].scene.document) as i32, 1), }; let ask = |app: &mut Self, name: String| { @@ -109,6 +123,23 @@ impl OpenCADStudio { self.block_redefine_mode = n as u8; self.persist_settings_if_changed(); } + "XEDIT" => { + if n != current { + self.push_undo_snapshot(i, "XEDIT"); + self.tabs[i].scene.document.header.xedit = n == 1; + self.tabs[i].dirty = true; + } + } + "XFADECTL" => { + crate::scene::cache::block_cache::set_refedit_fade_ctl(n); + self.persist_settings_if_changed(); + for tab in &mut self.tabs { + if !tab.is_start { + tab.scene.recolor_meshes(); + tab.scene.bump_geometry(); + } + } + } _ => { if n != current { self.push_undo_snapshot(i, "EXPLMODE"); diff --git a/src/app/commands/inquiry.rs b/src/app/commands/inquiry.rs index 5bd028c16..1b6831d70 100644 --- a/src/app/commands/inquiry.rs +++ b/src/app/commands/inquiry.rs @@ -457,35 +457,6 @@ impl OpenCADStudio { self.open_attedit_dialog(); } - // ── REFEDIT — in-place block editing ───────────────────────────── - "REFEDIT" => { - use crate::modules::draw::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( - crate::t!("REFEDIT: a session is already active. Use REFCLOSE first.") - .as_ref(), - ); - } 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(codec::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 Some(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)); - } - } - "BEDIT" => { use crate::modules::draw::modify::block_edit::BlockEditPickCommand; if self.tabs[i].refedit_session.is_some() { @@ -668,303 +639,6 @@ impl OpenCADStudio { } } - cmd if cmd.starts_with("REFEDIT_BEGIN:") => { - use crate::modules::draw::modify::refedit::{ - apply_insert_transform, RefEditSession, - }; - use codec::Handle; - - let handle_u64: u64 = cmd["REFEDIT_BEGIN:".len()..].parse().unwrap_or(0); - let insert_handle = Handle::new(handle_u64); - if self.reject_locked_edit(i, insert_handle) { - return Some(Task::none()); - } - - // Get INSERT entity. - let insert = match self.tabs[i].scene.document.get_entity(insert_handle) { - Some(codec::EntityType::Insert(ins)) => ins.clone(), - _ => { - self.command_line.push_error( - crate::t!("REFEDIT: selected object is not an INSERT.").as_ref(), - ); - return Some(Task::none()); - } - }; - - // Build the INSERT's full placement transform (OCS + rotation + - // scale, including non-uniform / mirrored, minus the block base - // point) and its inverse, so edits round-trip back to - // block-local coordinates on SAVE. The base point is part of - // the placement: without it the working copy sits Rot*Scale*base - // away from the reference the user picked. - let sx = insert.x_scale(); - let sy = insert.y_scale(); - let sz = insert.z_scale(); - let base = crate::scene::render_graph::block_base_point( - &self.tabs[i].scene.document, - &insert.block_name, - ); - let forward = crate::scene::render_graph::insert_transform_with_base(&insert, base); - let inverse = { - use codec::types::{Matrix3, Matrix4, Transform}; - let ocs_t = - Matrix4::from_matrix3(Matrix3::arbitrary_axis(insert.normal).transpose()); - let t_inv = Matrix4::translation( - -insert.insert_point.x, - -insert.insert_point.y, - -insert.insert_point.z, - ); - let r_inv = Matrix4::rotation_z(-insert.rotation); - let s_inv = Matrix4::scaling(1.0 / sx, 1.0 / sy, 1.0 / sz); - // inverse(T(-base)·OCS·T·R·S) = T(+base)·S⁻¹·R⁻¹·T⁻¹·OCSᵀ - Transform::from_matrix( - Matrix4::translation(base.x, base.y, base.z) - * (s_inv * r_inv * t_inv * ocs_t), - ) - }; - - // 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( - crate::tf!("REFEDIT: block \"{}\" not found.", insert.block_name) - .as_ref(), - ); - return Some(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, - codec::EntityType::Block(_) - | codec::EntityType::BlockEnd(_) - | codec::EntityType::AttributeDefinition(_) - ) - }) - .collect() - }; - - if block_entities.is_empty() { - self.command_line - .push_error(crate::t!("REFEDIT: block is empty.").as_ref()); - return Some(Task::none()); - } - - let session = RefEditSession { - block_name: insert.block_name.clone(), - br_handle, - temp_handles: vec![], - forward, - inverse, - // Everything allocated from here on is part of the session. - handle_watermark: self.tabs[i] - .scene - .document - .next_handle() - .max(self.tabs[i].scene.document.header.handle_seed), - }; - - 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 = codec::Handle::NULL; - entity.common_mut().owner_handle = codec::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(); - - // Fade everything except the entities being edited, so the - // surrounding drawing stays visible for context but the block's - // geometry stands out. (#136) - self.tabs[i] - .scene - .set_refedit_keep(Some(temp_handles.iter().copied().collect())); - - // 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; - - // No active command — the user edits the block's geometry freely - // (move, grips, draw, erase…) and runs REFCLOSE when done. (#136) - self.tabs[i].active_cmd = None; - self.command_line.push_info(crate::tf!( - "REFEDIT: Editing block \"{}\". Run REFCLOSE to save, REFCLOSE_DISCARD to cancel.", - insert.block_name - ).as_ref()); - } - - "REFCLOSE" => { - if self.tabs[i].refedit_session.is_some() { - use crate::modules::draw::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(crate::t!("REFCLOSE: no REFEDIT session active.").as_ref()); - } - } - - "REFCLOSE_SAVE" => { - use crate::modules::draw::modify::explode::normalize_entity_for_block; - use crate::modules::draw::modify::refedit::apply_insert_inverse_transform; - - let session = match self.tabs[i].refedit_session.take() { - Some(s) => s, - None => { - self.command_line - .push_error(crate::t!("REFCLOSE: no REFEDIT session active.").as_ref()); - return Some(Task::none()); - } - }; - - self.push_undo_snapshot(i, "REFCLOSE"); - - // The working set: the temp copies PLUS everything the user - // created in this space during the session (drawn / pasted / - // offset entities carry handles at or above the watermark) — - // those must fold into the block too, not stay behind in - // model space (#423). - let working: Vec = { - let space = self.tabs[i].scene.current_layout_block_handle_pub(); - let drawn = self.tabs[i].scene.document.entities().filter(|e| { - let c = e.common(); - c.owner_handle == space - && c.handle.value() >= session.handle_watermark - && !session.temp_handles.contains(&c.handle) - }); - session - .temp_handles - .iter() - .copied() - .chain(drawn.map(|e| e.common().handle)) - .collect() - }; - - // Collect the edited temp entities. - let new_entities: Vec = working - .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(&working); - - // 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 = codec::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( - crate::tf!( - "REFCLOSE: Block \"{}\" saved. All references updated.", - session.block_name - ) - .as_ref(), - ); - // End the edit fade before rebuilding, so the restored geometry - // recolours bright. (#136) - self.tabs[i].scene.set_refedit_keep(None); - // 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(crate::t!("REFCLOSE: no REFEDIT session active.").as_ref()); - return Some(Task::none()); - } - }; - // Remove the working set without modifying the block — the - // temp copies plus anything created during the session (#423). - let working: Vec = { - let space = self.tabs[i].scene.current_layout_block_handle_pub(); - let drawn = self.tabs[i].scene.document.entities().filter(|e| { - let c = e.common(); - c.owner_handle == space - && c.handle.value() >= session.handle_watermark - && !session.temp_handles.contains(&c.handle) - }); - session - .temp_handles - .iter() - .copied() - .chain(drawn.map(|e| e.common().handle)) - .collect() - }; - self.tabs[i].scene.erase_entities(&working); - self.tabs[i].scene.deselect_all(); - // End the edit fade — restore the drawing to full brightness. - self.tabs[i].scene.set_refedit_keep(None); - self.command_line - .push_output(crate::t!("REFCLOSE: Changes discarded.").as_ref()); - } - "ALIGN" => { use crate::modules::draw::modify::align::AlignCommand; diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 4c4029afb..abac53573 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -21,6 +21,7 @@ mod pc_extract; mod pdf_dialogs; pub(crate) mod pdf_import; mod pdf_underlay; +mod refedit; mod plotvars; mod point_cloud; pub(crate) mod sheet_set; @@ -331,6 +332,9 @@ impl OpenCADStudio { if let Some(t) = self.dispatch_dim(cmd, i) { return Some(t); } + if let Some(t) = self.dispatch_refedit(cmd, i) { + return Some(t); + } if let Some(t) = self.dispatch_inquiry(cmd, i) { return Some(t); } diff --git a/src/app/commands/refedit.rs b/src/app/commands/refedit.rs new file mode 100644 index 000000000..ce990f7e5 --- /dev/null +++ b/src/app/commands/refedit.rs @@ -0,0 +1,588 @@ +//! REFEDIT, -REFEDIT, REFSET and REFCLOSE: in-place editing of a block +//! reference or an external reference, with the reference's prompts and +//! messages. The session itself lives in `DocumentTab::refedit_session`. + +use crate::app::{Message, OpenCADStudio}; +use crate::modules::draw::modify::refedit::{ + apply_insert_inverse_transform, apply_insert_transform, level_hits, + nesting_chain, RefCloseCommand, RefEditOptionsCommand, RefEditPickCommand, RefEditSession, + RefSetCommand, NOT_CHECKED_OUT, +}; +use codec::{EntityType, Handle}; +use iced::Task; +use std::collections::HashSet; + +/// `** Command not allowed, NAME already checked out for editing **` +fn already_checked_out(name: &str) -> String { + crate::tf!("** Command not allowed, {} already checked out for editing **", name).into_owned() +} + +impl OpenCADStudio { + pub(super) fn dispatch_refedit(&mut self, cmd: &str, i: usize) -> Option> { + match cmd { + "REFEDIT" | "-REFEDIT" => Some(self.start_refedit(i, cmd == "-REFEDIT")), + "REFSET" => { + if self.tabs[i].refedit_session.is_none() { + self.command_line.push_error(crate::t!(NOT_CHECKED_OUT).as_ref()); + return Some(Task::none()); + } + self.command_line.push_info( + crate::t!("Transfer objects between the RefEdit working set and host drawing...") + .as_ref(), + ); + let command = RefSetCommand::new(); + use crate::command::CadCommand; + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + Some(Task::none()) + } + "REFCLOSE" => { + if self.tabs[i].refedit_session.is_none() { + self.command_line.push_error(crate::t!(NOT_CHECKED_OUT).as_ref()); + return Some(Task::none()); + } + let command = RefCloseCommand::new(); + use crate::command::CadCommand; + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + Some(Task::none()) + } + "REFCLOSE_SAVE" => { + self.close_refedit(i, true); + Some(Task::none()) + } + "REFCLOSE_DISCARD" => { + self.close_refedit(i, false); + Some(Task::none()) + } + _ if cmd.starts_with("REFEDIT_PICKED:") => { + self.refedit_picked(i, &cmd["REFEDIT_PICKED:".len()..]); + Some(Task::none()) + } + _ if cmd.starts_with("REFEDIT_BEGIN:") => { + self.begin_refedit(i, &cmd["REFEDIT_BEGIN:".len()..]); + Some(Task::none()) + } + _ if cmd.starts_with("REFSET_APPLY:") => { + self.apply_refset(i, cmd.ends_with("ADD")); + Some(Task::none()) + } + _ => None, + } + } + + /// Block references and xrefs of the current space. + fn refedit_references(&self, i: usize) -> HashSet { + let space = self.tabs[i].scene.current_layout_block_handle_pub(); + self.tabs[i] + .scene + .document + .entities() + .filter(|e| matches!(e, EntityType::Insert(_)) && e.common().owner_handle == space) + .map(|e| e.common().handle) + .collect() + } + + fn start_refedit(&mut self, i: usize, command_line: bool) -> Task { + if let Some(session) = &self.tabs[i].refedit_session { + let message = already_checked_out(&session.block_name); + self.command_line.push_error(&message); + return Task::none(); + } + let references = self.refedit_references(i); + // A single preselected reference is taken as the pick. + let selected: Vec = self.tabs[i] + .scene + .selected_entities() + .into_iter() + .map(|(h, _)| h) + .collect(); + if let [handle] = selected[..] { + if references.contains(&handle) { + self.tabs[i].scene.deselect_all(); + self.refedit_picked( + i, + &format!("{}:{}", command_line as u8, handle.value()), + ); + return Task::none(); + } + } + use crate::command::CadCommand; + let command = RefEditPickCommand::new(command_line, references); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + Task::none() + } + + /// `cli:handle[:x:y:z]` — the reference was picked (at the point). + fn refedit_picked(&mut self, i: usize, args: &str) { + let parts: Vec<&str> = args.split(':').collect(); + let command_line = parts.first() == Some(&"1"); + let Some(top) = parts.get(1).and_then(|v| v.parse::().ok()).map(Handle::new) else { + return; + }; + let point = (parts.len() >= 5) + .then(|| { + Some(glam::DVec3::new( + parts[2].parse().ok()?, + parts[3].parse().ok()?, + parts[4].parse().ok()?, + )) + }) + .flatten(); + self.tabs[i].scene.deselect_all(); + let chain = nesting_chain(&self.tabs[i].scene.document, top, point); + if chain.is_empty() { + self.command_line + .push_error(crate::t!("Object is not a valid reference").as_ref()); + return; + } + if command_line { + let hits = chain + .iter() + .map(|level| level_hits(&self.tabs[i].scene.document, level)) + .collect(); + let command = RefEditOptionsCommand::new(top, chain, hits); + use crate::command::CadCommand; + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } else { + // The dialog's defaults: the innermost reference, all objects, + // attribute definitions not displayed. + let path: Vec = chain[1..] + .iter() + .map(|l| l.insert.value().to_string()) + .collect(); + let _ = self.dispatch_command(&format!( + "REFEDIT_BEGIN:{}:{}:0:", + top.value(), + path.join(",") + )); + } + } + + /// `top[:path[:attributes[:nested]]]` — open the session. + fn begin_refedit(&mut self, i: usize, args: &str) { + let mut parts = args.split(':'); + let Some(top) = parts + .next() + .and_then(|v| v.parse::().ok()) + .map(Handle::new) + else { + return; + }; + let handles = |s: Option<&str>| -> Vec { + s.unwrap_or("") + .split(',') + .filter_map(|v| v.parse::().ok()) + .map(Handle::new) + .collect() + }; + let path = handles(parts.next()); + let attributes = parts.next() == Some("1"); + let nested = handles(parts.next()); + if self.tabs[i].refedit_session.is_some() || self.reject_locked_edit(i, top) { + return; + } + let document = &self.tabs[i].scene.document; + let mut chain = nesting_chain(document, top, None); + if chain.is_empty() { + self.command_line + .push_error(crate::t!("Object is not a valid reference").as_ref()); + return; + } + // Follow the stored path of definition-owned references. + for handle in path { + let Some(EntityType::Insert(inner)) = document.get_entity(handle) else { + break; + }; + let base = crate::scene::render_graph::block_base_point(document, &inner.block_name); + let local = crate::scene::render_graph::insert_transform_with_base(inner, base); + let outer = chain.last().expect("non-empty chain").forward.clone(); + chain.push(crate::modules::draw::modify::refedit::ChainLevel { + insert: handle, + block_name: inner.block_name.clone(), + forward: local.then(&outer), + }); + } + let level = chain.last().expect("non-empty chain").clone(); + let Some(br) = document.block_records.get(&level.block_name) else { + return; + }; + let br_handle = br.handle; + let xref_path = (br.flags.is_xref || br.flags.is_xref_overlay) + .then(|| br.xref_path.clone()) + .filter(|p| !p.is_empty()); + let extracted: Vec = br + .entity_handles + .iter() + .copied() + .filter(|h| match document.get_entity(*h) { + Some(EntityType::Block(_) | EntityType::BlockEnd(_)) | None => false, + Some(EntityType::AttributeDefinition(_)) => attributes, + Some(_) => nested.is_empty() || nested.contains(h), + }) + .collect(); + let Some(inverse) = + crate::modules::draw::modify::refedit::inverse_placement(&level.forward) + else { + return; + }; + let session = RefEditSession { + block_name: level.block_name.clone(), + br_handle, + temp_handles: Vec::new(), + extracted: extracted.clone(), + added: HashSet::new(), + removed: HashSet::new(), + forward: level.forward.clone(), + inverse, + // Everything allocated from here on is part of the session. + handle_watermark: document.next_handle().max(document.header.handle_seed), + xref_path, + }; + let copies: Vec = extracted + .iter() + .filter_map(|h| document.get_entity(*h).cloned()) + .collect(); + + self.push_undo_snapshot(i, "REFEDIT"); + let mut temp_handles = Vec::new(); + for mut entity in copies { + apply_insert_transform(&mut entity, &session); + entity.common_mut().handle = Handle::NULL; + entity.common_mut().owner_handle = Handle::NULL; + temp_handles.push(self.tabs[i].scene.add_entity(entity)); + } + let mut session = session; + session.temp_handles = temp_handles.clone(); + self.tabs[i].refedit_session = Some(session); + self.tabs[i].refedit_closed.clear(); + // Fade everything outside the working set by XFADECTL. (#136) + self.tabs[i] + .scene + .set_refedit_keep(Some(temp_handles.iter().copied().collect::>())); + self.tabs[i].scene.refedit_lock = true; + self.tabs[i].scene.deselect_all(); + self.tabs[i].dirty = true; + // No active command — the definition's objects are edited freely + // (move, grips, draw, erase…) until REFCLOSE. + self.tabs[i].active_cmd = None; + } + + /// REFSET Add / Remove on the current selection. + fn apply_refset(&mut self, i: usize, add: bool) { + let Some(mut session) = self.tabs[i].refedit_session.take() else { + self.command_line.push_error(crate::t!(NOT_CHECKED_OUT).as_ref()); + return; + }; + let picked: Vec = self.tabs[i] + .scene + .selected_entities() + .into_iter() + .map(|(h, _)| h) + .collect(); + let mut moved = 0usize; + for h in picked { + let inside = session.in_working_set(h); + if add && !inside { + session.removed.remove(&h); + if !session.in_working_set(h) { + session.added.insert(h); + } + moved += 1; + } else if !add && inside { + session.added.remove(&h); + if session.in_working_set(h) { + session.removed.insert(h); + } + moved += 1; + } + } + let keep: rustc_hash::FxHashSet = self.tabs[i] + .scene + .document + .entities() + .map(|e| e.common().handle) + .filter(|h| session.in_working_set(*h)) + .collect(); + self.tabs[i].refedit_session = Some(session); + self.tabs[i].scene.set_refedit_keep(Some(keep)); + self.tabs[i].scene.deselect_all(); + self.command_line.push_output(&if add { + crate::tf!("{} Added to working set", moved).into_owned() + } else { + crate::tf!("{} Removed from working set", moved).into_owned() + }); + } + + /// REFCLOSE Save (`save`) or Discard. + fn close_refedit(&mut self, i: usize, save: bool) { + let Some(session) = self.tabs[i].refedit_session.take() else { + self.command_line.push_error(crate::t!(NOT_CHECKED_OUT).as_ref()); + return; + }; + self.push_undo_snapshot(i, "REFCLOSE"); + self.command_line + .push_output(crate::t!("Regenerating model.").as_ref()); + let space = self.tabs[i].scene.current_layout_block_handle_pub(); + // The working set: the copies still in it, everything created in this + // space during the session (#423) and the host objects added with + // REFSET — less what REFSET removed. + let created: Vec = self.tabs[i] + .scene + .document + .entities() + .filter(|e| { + let c = e.common(); + c.owner_handle == space && c.handle.value() >= session.handle_watermark + }) + .map(|e| e.common().handle) + .collect(); + let working: Vec = created + .iter() + .chain(session.added.iter()) + .copied() + .filter(|h| session.in_working_set(*h)) + .collect::>() + .into_iter() + .collect(); + if !save { + // The copies and new objects go; host objects added with REFSET + // stay where they are, and copies moved out with REFSET Remove + // stay in the host. + let drop: Vec = working + .iter() + .copied() + .filter(|h| !session.added.contains(h)) + .collect(); + self.tabs[i].scene.erase_entities(&drop); + self.finish_refedit_close(i, session); + return; + } + + use crate::modules::draw::modify::explode::normalize_entity_for_block; + let originals: HashSet = session.temp_handles.iter().copied().collect(); + let added_count = working.iter().filter(|h| !originals.contains(h)).count(); + let removed_count = session.extracted.len() + - working.iter().filter(|h| originals.contains(h)).count(); + let edited: Vec = working + .iter() + .filter_map(|h| self.tabs[i].scene.document.get_entity(*h).cloned()) + .map(|mut entity| { + apply_insert_inverse_transform(&mut entity, &session); + let mut entity = normalize_entity_for_block(entity); + entity.common_mut().handle = Handle::NULL; + entity.common_mut().owner_handle = session.br_handle; + entity + }) + .collect(); + self.tabs[i].scene.erase_entities(&working); + // The extracted definition objects are replaced; the rest stay. + for h in &session.extracted { + self.tabs[i].scene.document.remove_entity(*h); + } + if let Some(br) = self.tabs[i] + .scene + .document + .block_records + .get_mut(&session.block_name) + { + let extracted: HashSet = session.extracted.iter().copied().collect(); + br.entity_handles.retain(|h| !extracted.contains(h)); + } + for entity in edited.iter().cloned() { + let _ = self.tabs[i].scene.document.add_entity(entity); + } + let instances = self.tabs[i] + .scene + .document + .entities() + .filter(|e| { + matches!(e, EntityType::Insert(ins) + if ins.block_name.eq_ignore_ascii_case(&session.block_name)) + }) + .count(); + let name = session.block_name.clone(); + match added_count { + 0 => {} + 1 => self + .command_line + .push_output(crate::tf!("1 object added to {}", name).as_ref()), + n => self + .command_line + .push_output(crate::tf!("{} objects added to {}", n, name).as_ref()), + } + match removed_count { + 0 => {} + 1 => self + .command_line + .push_output(crate::tf!("1 object removed from {}", name).as_ref()), + n => self + .command_line + .push_output(crate::tf!("{} objects removed from {}", n, name).as_ref()), + } + let reloaded = match &session.xref_path { + Some(raw) => match self.write_back_xref(i, &session, raw, edited) { + Ok(()) => true, + Err(error) => { + self.command_line.push_error(&error); + false + } + }, + None => false, + }; + let updated = match (session.xref_path.is_some(), instances) { + (true, 1) => crate::t!("1 xref instance updated").into_owned(), + (true, n) => crate::tf!("{} xref instances updated", n).into_owned(), + (false, 1) => crate::t!("1 block instance updated").into_owned(), + (false, n) => crate::tf!("{} block instances updated", n).into_owned(), + }; + self.command_line.push_output(&updated); + self.command_line.push_output(&if reloaded { + crate::tf!("{} redefined and reloaded.", name).into_owned() + } else { + crate::tf!("{} redefined.", name).into_owned() + }); + self.finish_refedit_close(i, session); + // Rebuild hatch/image/mesh caches since block content changed. + self.tabs[i].scene.rebuild_derived_caches(); + } + + fn finish_refedit_close(&mut self, i: usize, session: RefEditSession) { + self.tabs[i].refedit_closed.push(session); + self.tabs[i].scene.refedit_lock = false; + self.tabs[i].scene.set_refedit_keep(None); + self.tabs[i].scene.deselect_all(); + self.tabs[i].dirty = true; + } + + /// Write the edited definition into the referenced drawing and reload it. + fn write_back_xref( + &mut self, + i: usize, + session: &RefEditSession, + raw: &str, + edited: Vec, + ) -> Result<(), String> { + let base_dir = self.tabs[i] + .current_path + .as_ref() + .and_then(|p| p.parent().map(std::path::Path::to_path_buf)) + .unwrap_or_default(); + let path = crate::io::xref::resolve_path(raw, &base_dir) + .ok_or_else(|| format!("\"{raw}\" cannot be found."))?; + let mut source = crate::io::load_file(&path)?; + let prefix = format!("{}|", session.block_name); + let strip = |name: &str| -> String { + if name.len() > prefix.len() + && name.is_char_boundary(prefix.len()) + && name[..prefix.len()].eq_ignore_ascii_case(&prefix) + { + name[prefix.len()..].to_string() + } else { + name.to_string() + } + }; + // Dependencies come from the host (where they carry the xref prefix) + // and go back under their own names. + let mut deps = crate::app::ClipboardDeps::capture(&self.tabs[i].scene.document, &edited); + { + use codec::TableEntry; + for layer in &mut deps.layers { + let name = strip(layer.name()); + layer.name = name; + } + for linetype in &mut deps.linetypes { + let name = strip(linetype.name()); + linetype.name = name; + } + for style in &mut deps.text_styles { + let name = strip(style.name()); + style.name = name; + } + } + for block in &mut deps.blocks { + block.name = strip(&block.name); + } + let entities: Vec = edited + .into_iter() + .map(|mut entity| { + let common = entity.common_mut(); + common.layer = strip(&common.layer); + common.linetype = strip(&common.linetype); + common.layer_handle = None; + common.linetype_handle = None; + common.owner_handle = Handle::NULL; + match &mut entity { + EntityType::Insert(insert) => insert.block_name = strip(&insert.block_name), + EntityType::Text(text) => text.style = strip(&text.style), + EntityType::MText(text) => text.style = strip(&text.style), + _ => {} + } + entity + }) + .collect(); + let model: Vec = source + .model_space_entities() + .map(|e| e.common().handle) + .collect(); + for h in model { + source.remove_entity(h); + } + crate::app::command_driver::clipboard::paste_entities_kernel( + &mut source, + &entities, + &deps, + None, + crate::app::command_driver::clipboard::PasteSpace::Model, + ); + crate::io::save(&source, &path)?; + let keys: rustc_hash::FxHashSet = [session.br_handle].into_iter().collect(); + self.tabs[i].xref_stat_cache.0.remove(&session.br_handle.value()); + let _ = crate::io::xref::resolve_xrefs_for_keys( + &mut self.tabs[i].scene.document, + &base_dir, + &keys, + ); + Ok(()) + } + + /// Undo of REFCLOSE checks the reference out again; undo of REFEDIT ends + /// the session (their redo does the reverse). + pub(in crate::app) fn refedit_after_history_step(&mut self, i: usize, label: &str, undo: bool) { + match (label, undo) { + ("REFCLOSE", true) | ("REFEDIT", false) => { + let reopened = if undo { + self.tabs[i].refedit_closed.pop() + } else { + self.tabs[i].refedit_reopen.pop() + }; + if let Some(session) = reopened { + let keep: rustc_hash::FxHashSet = self.tabs[i] + .scene + .document + .entities() + .map(|e| e.common().handle) + .filter(|h| session.in_working_set(*h)) + .collect(); + self.tabs[i].refedit_session = Some(session); + self.tabs[i].scene.refedit_lock = true; + self.tabs[i].scene.set_refedit_keep(Some(keep)); + } + } + ("REFCLOSE", false) | ("REFEDIT", true) => { + if let Some(session) = self.tabs[i].refedit_session.take() { + if undo { + self.tabs[i].refedit_reopen.push(session); + } else { + self.tabs[i].refedit_closed.push(session); + } + self.tabs[i].scene.refedit_lock = false; + self.tabs[i].scene.set_refedit_keep(None); + } + } + _ => {} + } + } +} diff --git a/src/app/document.rs b/src/app/document.rs index 841609a4e..2fcceaaeb 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -229,6 +229,10 @@ pub(super) struct DocumentTab { pub(super) paper_bg_color: Option<[f32; 4]>, /// Active REFEDIT session, if any. pub(super) refedit_session: Option, + /// Sessions closed by REFCLOSE, reopened by undoing it. + pub(super) refedit_closed: Vec, + /// Sessions ended by undoing REFEDIT, reopened by redoing it. + pub(super) refedit_reopen: Vec, /// COUNT mode, while it is on. pub(crate) count: Option, /// Open BEDIT block tabs. Definitions are edited live; each tab owns its @@ -665,6 +669,8 @@ impl DocumentTab { bg_color: None, paper_bg_color: None, refedit_session: None, + refedit_closed: Vec::new(), + refedit_reopen: Vec::new(), count: None, block_edits: Vec::new(), active_block_edit: None, diff --git a/src/app/history.rs b/src/app/history.rs index 389c2b3ba..c294f9aca 100644 --- a/src/app/history.rs +++ b/src/app/history.rs @@ -1208,6 +1208,7 @@ impl OpenCADStudio { break; }; last_label = snapshot.label().to_string(); + let step_label = last_label.clone(); match snapshot { HistorySnapshot::Delta(mut d) => { // Symmetric: undo applies the before side, then the same @@ -1242,6 +1243,7 @@ impl OpenCADStudio { .push(HistorySnapshot::ObjectVisibility(v)); } } + self.refedit_after_history_step(i, &step_label, true); } self.finish_history_apply(i, had_full, layer_panel_changed, &changes); self.command_line @@ -1268,6 +1270,7 @@ impl OpenCADStudio { break; }; last_label = snapshot.label().to_string(); + let step_label = last_label.clone(); match snapshot { HistorySnapshot::Delta(mut d) => { layer_panel_changed |= d.structure.as_ref().is_some_and(|structure| { @@ -1299,6 +1302,7 @@ impl OpenCADStudio { .push(HistorySnapshot::ObjectVisibility(v)); } } + self.refedit_after_history_step(i, &step_label, false); } self.finish_history_apply(i, had_full, layer_panel_changed, &changes); self.command_line diff --git a/src/app/settings.rs b/src/app/settings.rs index cc87a2f73..834c1874a 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -652,6 +652,9 @@ pub struct UserSettings { /// BLOCKNAVIGATE (default "."). #[serde(default = "default_block_navigate")] pub block_navigate: String, + /// XFADECTL: how far objects outside the REFEDIT working set fade (0–90, default 70). + #[serde(default = "default_xfadectl")] + pub xfadectl: u8, /// COUNTCOLOR (default 3). #[serde(default = "default_count_color")] pub count_color: i16, @@ -722,6 +725,10 @@ fn default_block_view() -> u8 { 1 } +fn default_xfadectl() -> u8 { + 70 +} + fn default_block_mru_list() -> u8 { 50 } @@ -931,6 +938,7 @@ impl Default for UserSettings { block_mru_list: 50, block_redefine_mode: 1, block_navigate: ".".to_string(), + xfadectl: 70, count_color: 3, count_error_color: 1, count_service: true, diff --git a/src/app/update/file.rs b/src/app/update/file.rs index c8577ee80..4612a082c 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -873,6 +873,7 @@ impl OpenCADStudio { block_mru_list: self.block_mru_list, block_redefine_mode: self.block_redefine_mode, block_navigate: self.block_navigate.clone(), + xfadectl: crate::scene::cache::block_cache::refedit_fade_ctl() as u8, count_color: self.count_palette.color, count_error_color: self.count_palette.error_color, count_service: self.count_palette.service, @@ -1034,6 +1035,7 @@ impl OpenCADStudio { self.block_mru_list = s.block_mru_list.min(100); self.block_redefine_mode = s.block_redefine_mode.min(2); self.block_navigate = s.block_navigate.clone(); + crate::scene::cache::block_cache::set_refedit_fade_ctl(i32::from(s.xfadectl.min(90))); self.count_palette.color = s.count_color; self.count_palette.error_color = s.count_error_color; self.count_palette.service = s.count_service; diff --git a/src/io/xref.rs b/src/io/xref.rs index e464a3b12..d051e12c8 100644 --- a/src/io/xref.rs +++ b/src/io/xref.rs @@ -654,14 +654,14 @@ pub fn layer_overrides(doc: &CadDocument, name: &str, raw_path: &str, base_dir: /// to the name-keyed bytes registry instead (trailing-name match); the /// returned path is the registry key. #[cfg(target_arch = "wasm32")] -fn resolve_path(raw: &str, _base_dir: &Path) -> Option { +pub(crate) fn resolve_path(raw: &str, _base_dir: &Path) -> Option { find_registered_source(raw).map(PathBuf::from) } /// Try to build an absolute path from a raw xref path string. /// Handles absolute paths, relative paths, and Windows-style separators. #[cfg(not(target_arch = "wasm32"))] -fn resolve_path(raw: &str, base_dir: &Path) -> Option { +pub(crate) fn resolve_path(raw: &str, base_dir: &Path) -> Option { let normalised = raw.replace('\\', "/"); let p = PathBuf::from(&normalised); diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 1a7be67e5..d903c1804 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -3,6 +3,36 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static str)> { match source { + "Select reference:" => Some(("ref-edit", "select-reference")), + "Reference not found." => Some(("ref-edit", "reference-not-found")), + "Object is not a valid reference" => Some(("ref-edit", "not-valid-reference")), + "1 entity added" => Some(("ref-edit", "one-entity-added")), + "Select nesting level [Ok/Next] :" => Some(("ref-edit", "nesting-level")), + "Enter object selection method [All/Nested] :" => Some(("ref-edit", "selection-method")), + "Select nested objects:" => Some(("ref-edit", "select-nested")), + "Display attribute definitions [Yes/No] :" => Some(("ref-edit", "display-attdefs")), + "0 items selected" => Some(("ref-edit", "zero-items-selected")), + "1 item selected" => Some(("ref-edit", "one-item-selected")), + "{} items selected" => Some(("ref-edit", "items-selected")), + "Enter an option [Add/Remove] :" => Some(("ref-edit", "refset-option")), + "Enter option [Save/Discard reference changes] :" => Some(("ref-edit", "refclose-option")), + "** Command not allowed unless a reference is checked out with REFEDIT command **" => Some(("ref-edit", "not-checked-out")), + "** Command not allowed, {} already checked out for editing **" => Some(("ref-edit", "already-checked-out")), + "Transfer objects between the RefEdit working set and host drawing..." => Some(("ref-edit", "transfer-objects")), + "{} Added to working set" => Some(("ref-edit", "added-to-working-set")), + "{} Removed from working set" => Some(("ref-edit", "removed-from-working-set")), + "1 object added to {}" => Some(("ref-edit", "one-object-added")), + "{} objects added to {}" => Some(("ref-edit", "objects-added")), + "1 object removed from {}" => Some(("ref-edit", "one-object-removed")), + "{} objects removed from {}" => Some(("ref-edit", "objects-removed")), + "1 xref instance updated" => Some(("ref-edit", "one-xref-instance")), + "{} xref instances updated" => Some(("ref-edit", "xref-instances")), + "1 block instance updated" => Some(("ref-edit", "one-block-instance")), + "{} block instances updated" => Some(("ref-edit", "block-instances")), + "{} redefined and reloaded." => Some(("ref-edit", "redefined-reloaded")), + "{} redefined." => Some(("ref-edit", "redefined")), + "Ok" => Some(("ref-edit", "ok")), + "Nested" => Some(("ref-edit", "nested")), "Operational Symbols" => Some(("property-patch", "patch-07")), "Decimal Separator" => Some(("property-patch", "patch-06")), "Open 3Dconnexion settings..." => Some(("property-patch", "patch-05")), diff --git a/src/modules/draw/modify/refedit.rs b/src/modules/draw/modify/refedit.rs index 1dc922c31..792628795 100644 --- a/src/modules/draw/modify/refedit.rs +++ b/src/modules/draw/modify/refedit.rs @@ -1,23 +1,23 @@ -// REFEDIT — in-place block reference editing. +// REFEDIT / -REFEDIT / REFSET / REFCLOSE — in-place 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. +// 1. REFEDIT (or -REFEDIT on the command line) picks a block reference or +// xref; the nesting chain under the pick is offered innermost first and +// `Next` steps outward. +// 2. The chosen definition's objects (all of them, or the nested objects +// picked) are copied into the current space with the composed placement +// of the chain. Everything else fades by XFADECTL. +// 3. The user edits them with normal commands; REFSET moves host objects +// into the working set or working-set objects out to the host. +// 4. REFCLOSE Save writes the working set back into the definition (and +// into the referenced file for an xref); Discard drops the copies. use crate::t; use codec::{EntityType, Handle}; use glam::DVec3; +use std::collections::HashSet; -use crate::command::{CadCommand, CmdResult}; +use crate::command::{CadCommand, CmdOption, CmdResult}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; /// Right-edge side-toolbar buttons shown while a REFEDIT session is active: @@ -39,22 +39,33 @@ pub fn refedit_tools() -> Vec { ] } +/// Printed by REFSET and REFCLOSE outside a session. +pub const NOT_CHECKED_OUT: &str = + "** Command not allowed unless a reference is checked out with REFEDIT command **"; + // ── Session state (held in DocumentTab) ─────────────────────────────────── /// Active REFEDIT session. Lives in `DocumentTab::refedit_session`. #[derive(Debug, Clone)] pub struct RefEditSession { - /// Name of the block being edited. + /// Name of the definition being edited (REFEDITNAME). 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. + /// The working copies of the extracted definition objects. pub temp_handles: Vec, - // ── INSERT placement, as full affine transforms ─────────────────── - /// Block-local → world (the INSERT's `get_transform`). Handles OCS, - /// rotation and non-uniform / mirrored scale in one matrix. + /// The definition objects that were copied out, index-aligned with + /// `temp_handles`. Objects not extracted (attribute definitions when they + /// are not displayed, objects not picked in Nested mode) stay in the + /// definition untouched. + pub extracted: Vec, + /// Host objects moved into the working set with REFSET Add. + pub added: HashSet, + /// Working-set objects moved out to the host with REFSET Remove. + pub removed: HashSet, + /// Definition-local → world: the composed placement of the chain. pub forward: codec::types::Transform, - /// World → block-local, applied on SAVE to bring edits back. + /// World → definition-local, applied on SAVE to bring edits back. pub inverse: codec::types::Transform, /// Handle watermark captured before the temp copies were created: every /// entity allocated at or above it during the session (drawn, pasted, @@ -62,51 +73,482 @@ pub struct RefEditSession { /// and Discard removes it (#423). Catches every creation path without /// per-command hooks. pub handle_watermark: u64, + /// The referenced file when the definition is an xref. + pub xref_path: Option, } -// ── REFEDIT pick command ─────────────────────────────────────────────────── +impl RefEditSession { + /// Whether `handle` (a host-space object) is in the working set. + pub fn in_working_set(&self, handle: Handle) -> bool { + if self.removed.contains(&handle) { + return false; + } + self.temp_handles.contains(&handle) + || self.added.contains(&handle) + || handle.value() >= self.handle_watermark + } +} -/// Step 1: wait for the user to pick a single INSERT entity. -pub struct RefEditPickCommand; +// ── Nesting chain under a pick ──────────────────────────────────────────── + +/// One level of the nesting chain: the reference (top-level object handle for +/// level 0, the definition-owned INSERT handle below it) and its definition. +#[derive(Debug, Clone)] +pub struct ChainLevel { + pub insert: Handle, + pub block_name: String, + /// Definition-local → world for this level. + pub forward: codec::types::Transform, +} + +/// The pickable objects of one definition, in world space, for Nested +/// selection: (definition-owned handle, polyline paths of its leaves). +pub type LevelHits = Vec<(Handle, Vec>)>; + +/// World paths of an entity for hit-testing (curves sampled by the renderer; +/// block references recurse into their definitions). +pub fn hit_paths( + entity: &EntityType, + document: &codec::CadDocument, + depth: usize, +) -> Vec> { + use crate::entities::traits::EntityTypeOps; + use crate::scene::convert::acad_to_render::RenderObject; + if let EntityType::Insert(insert) = entity { + if depth > 8 { + return Vec::new(); + } + let Some(br) = document.block_records.get(&insert.block_name) else { + return Vec::new(); + }; + let base = crate::scene::render_graph::block_base_point(document, &insert.block_name); + let placement = crate::scene::render_graph::insert_transform_with_base(insert, base); + return br + .entity_handles + .iter() + .filter_map(|h| document.get_entity(*h)) + .filter(|e| !matches!(e, EntityType::Block(_) | EntityType::BlockEnd(_))) + .flat_map(|child| { + let mut child = child.clone(); + child.as_entity_mut().apply_transform(&placement); + hit_paths(&child, document, depth + 1) + }) + .collect(); + } + match entity + .to_render_entity(document) + .map(|render| render.object) + { + Some(RenderObject::Dot(point)) => vec![vec![point]], + Some( + RenderObject::Lines(points) + | RenderObject::SegmentedLines(points) + | RenderObject::TaperedLines(points, _) + | RenderObject::BoundaryLines { points, .. }, + ) => vec![points], + _ => Vec::new(), + } +} + +/// Distance from `point` to a set of paths. +pub fn paths_distance(paths: &[Vec<[f64; 3]>], point: DVec3) -> Option { + let p = kernel::space::Vec3::from(point.to_array()); + paths + .iter() + .filter_map(|path| { + if path.len() == 1 { + return Some(p.distance(kernel::space::Vec3::from(path[0]))); + } + path.windows(2) + .filter(|s| s.iter().flatten().all(|v| v.is_finite())) + .map(|s| p.distance_to_segment(s[0].into(), s[1].into())) + .min_by(f64::total_cmp) + }) + .min_by(f64::total_cmp) +} + +/// The world-space objects of `level`'s definition with their hit paths. +pub fn level_hits(document: &codec::CadDocument, level: &ChainLevel) -> LevelHits { + let Some(br) = document.block_records.get(&level.block_name) else { + return Vec::new(); + }; + br.entity_handles + .iter() + .filter_map(|h| document.get_entity(*h).map(|e| (*h, e))) + .filter(|(_, e)| { + !matches!( + e, + EntityType::Block(_) | EntityType::BlockEnd(_) | EntityType::AttributeDefinition(_) + ) + }) + .map(|(h, e)| { + let mut world = e.clone(); + world.as_entity_mut().apply_transform(&level.forward); + (h, hit_paths(&world, document, 0)) + }) + .collect() +} + +/// The nesting chain of `top` under `point`, outermost first: the top-level +/// reference, then every definition-owned reference the nearest object under +/// the point sits in. Without a point only the top level is returned. +pub fn nesting_chain( + document: &codec::CadDocument, + top: Handle, + point: Option, +) -> Vec { + let Some(EntityType::Insert(insert)) = document.get_entity(top) else { + return Vec::new(); + }; + let base = crate::scene::render_graph::block_base_point(document, &insert.block_name); + let mut chain = vec![ChainLevel { + insert: top, + block_name: insert.block_name.clone(), + forward: crate::scene::render_graph::insert_transform_with_base(insert, base), + }]; + let Some(point) = point else { + return chain; + }; + for _ in 0..16 { + let level = chain.last().expect("chain starts with the top level").clone(); + let nearest = level_hits(document, &level) + .into_iter() + .filter_map(|(h, paths)| paths_distance(&paths, point).map(|d| (h, d))) + .min_by(|a, b| a.1.total_cmp(&b.1)); + let Some((handle, _)) = nearest else { + break; + }; + let Some(EntityType::Insert(inner)) = document.get_entity(handle) else { + break; + }; + if chain + .iter() + .any(|l| l.block_name.eq_ignore_ascii_case(&inner.block_name)) + { + break; + } + let base = crate::scene::render_graph::block_base_point(document, &inner.block_name); + let local = crate::scene::render_graph::insert_transform_with_base(inner, base); + chain.push(ChainLevel { + insert: handle, + block_name: inner.block_name.clone(), + forward: local.then(&level.forward), + }); + } + chain +} + +// ── REFEDIT / -REFEDIT pick command ─────────────────────────────────────── + +/// `Select reference:` — a block reference or xref of the current space. +pub struct RefEditPickCommand { + name: &'static str, + references: HashSet, +} impl RefEditPickCommand { - pub fn new() -> Self { - Self + /// `command_line` selects -REFEDIT (prompts) over REFEDIT. + pub fn new(command_line: bool, references: HashSet) -> Self { + Self { + name: if command_line { "-REFEDIT" } else { "REFEDIT" }, + references, + } } } impl CadCommand for RefEditPickCommand { fn name(&self) -> &'static str { - "REFEDIT" + self.name } fn prompt(&self) -> String { - crate::t!("REFEDIT Select block reference to edit:").into_owned() + t!("Select reference:").into_owned() } fn needs_entity_pick(&self) -> bool { true } - fn on_entity_pick(&mut self, handle: Handle, _pt: DVec3) -> CmdResult { + fn on_entity_pick(&mut self, handle: Handle, pt: DVec3) -> CmdResult { if handle.is_null() { - return CmdResult::NeedPoint; + return CmdResult::ReportError(t!("Reference not found.").into_owned()); } - // 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]) + if !self.references.contains(&handle) { + return CmdResult::ReportError(t!("Object is not a valid reference").into_owned()); + } + CmdResult::Relaunch( + format!( + "REFEDIT_PICKED:{}:{}:{}:{}:{}", + (self.name == "-REFEDIT") as u8, + handle.value(), + pt.x, + pt.y, + pt.z + ), + vec![handle], + ) } + fn on_point(&mut self, _pt: DVec3) -> CmdResult { + CmdResult::ReportError(t!("Reference not found.").into_owned()) + } + fn on_enter(&mut self) -> CmdResult { + CmdResult::ReportError(t!("Reference not found.").into_owned()) + } +} + +// ── -REFEDIT options after the pick ─────────────────────────────────────── + +enum OptionStep { + Nesting, + Method, + Nested, + Attributes, +} + +/// `Select nesting level [Ok/Next] :` → `Enter object selection method +/// [All/Nested] :` (→ `Select nested objects:`) → `Display attribute +/// definitions [Yes/No] :`, then the session starts. +pub struct RefEditOptionsCommand { + top: Handle, + chain: Vec, + hits: Vec, + level: usize, + step: OptionStep, + nested: Vec, +} + +impl RefEditOptionsCommand { + pub fn new(top: Handle, chain: Vec, hits: Vec) -> Self { + let level = chain.len().saturating_sub(1); + Self { + top, + chain, + hits, + level, + step: OptionStep::Nesting, + nested: Vec::new(), + } + } + + fn begin(&self, attributes: bool) -> CmdResult { + let path: Vec = self.chain[1..=self.level] + .iter() + .map(|l| l.insert.value().to_string()) + .collect(); + let nested: Vec = self.nested.iter().map(|h| h.value().to_string()).collect(); + CmdResult::Relaunch( + format!( + "REFEDIT_BEGIN:{}:{}:{}:{}", + self.top.value(), + path.join(","), + attributes as u8, + nested.join(",") + ), + vec![], + ) + } + + fn pick_nested(&mut self, point: DVec3) -> CmdResult { + let nearest = self + .hits + .get(self.level) + .into_iter() + .flatten() + .filter_map(|(h, paths)| paths_distance(paths, point).map(|d| (*h, d))) + .min_by(|a, b| a.1.total_cmp(&b.1)); + match nearest { + Some((h, _)) if !self.nested.contains(&h) => { + self.nested.push(h); + CmdResult::ReportMeasurement(t!("1 entity added").into_owned()) + } + _ => CmdResult::NeedPoint, + } + } +} + +impl CadCommand for RefEditOptionsCommand { + fn name(&self) -> &'static str { + "-REFEDIT" + } + fn prompt(&self) -> String { + match self.step { + OptionStep::Nesting => t!("Select nesting level [Ok/Next] :").into_owned(), + OptionStep::Method => { + t!("Enter object selection method [All/Nested] :").into_owned() + } + OptionStep::Nested => t!("Select nested objects:").into_owned(), + OptionStep::Attributes => { + t!("Display attribute definitions [Yes/No] :").into_owned() + } + } + } + fn options(&self) -> Vec { + match self.step { + OptionStep::Nesting => vec![CmdOption::new("Ok", "O"), CmdOption::new("Next", "N")], + OptionStep::Method => vec![CmdOption::new("All", "A"), CmdOption::new("Nested", "N")], + OptionStep::Nested => Vec::new(), + OptionStep::Attributes => { + vec![CmdOption::new("Yes", "Y"), CmdOption::new("No", "N")] + } + } + } + fn wants_text_input(&self) -> bool { + !matches!(self.step, OptionStep::Nested) + } + fn needs_entity_pick(&self) -> bool { + matches!(self.step, OptionStep::Nested) + } + fn on_entity_pick(&mut self, _handle: Handle, pt: DVec3) -> CmdResult { + self.pick_nested(pt) + } + fn on_text_input(&mut self, text: &str) -> Option { + let word = text.trim().to_ascii_uppercase(); + if word.is_empty() { + return None; + } + let matches = |full: &str| full.starts_with(word.as_str()); + Some(match self.step { + OptionStep::Nesting if matches("OK") => { + self.step = OptionStep::Method; + CmdResult::NeedPoint + } + OptionStep::Nesting if matches("NEXT") => self.on_enter(), + OptionStep::Method if matches("ALL") => self.on_enter(), + OptionStep::Method if matches("NESTED") => { + self.step = OptionStep::Nested; + CmdResult::NeedPoint + } + OptionStep::Attributes if matches("YES") => self.begin(true), + OptionStep::Attributes if matches("NO") => self.begin(false), + OptionStep::Nested => return None, + _ => CmdResult::ReportError(t!("Invalid option keyword.").into_owned()), + }) + } + fn on_point(&mut self, pt: DVec3) -> CmdResult { + match self.step { + OptionStep::Nested => self.pick_nested(pt), + _ => CmdResult::NeedPoint, + } + } + fn on_enter(&mut self) -> CmdResult { + match self.step { + OptionStep::Nesting => { + // Next steps outward and wraps back to the innermost level. + self.level = if self.level == 0 { + self.chain.len().saturating_sub(1) + } else { + self.level - 1 + }; + CmdResult::NeedPoint + } + OptionStep::Method => { + self.step = OptionStep::Attributes; + CmdResult::NeedPoint + } + OptionStep::Nested => { + let n = self.nested.len(); + if n == 0 { + return CmdResult::CancelWithMessage(t!("0 items selected").into_owned()); + } + self.step = OptionStep::Attributes; + CmdResult::ReportMeasurement(if n == 1 { + t!("1 item selected").into_owned() + } else { + crate::tf!("{} items selected", n).into_owned() + }) + } + OptionStep::Attributes => self.begin(false), + } + } +} + +// ── REFSET ──────────────────────────────────────────────────────────────── + +/// `Enter an option [Add/Remove] :` then `Select objects:`. +pub struct RefSetCommand { + add: bool, + gathering: bool, + selected: Vec, +} + +impl RefSetCommand { + pub fn new() -> Self { + Self { + add: true, + gathering: false, + selected: Vec::new(), + } + } +} + +impl CadCommand for RefSetCommand { + fn name(&self) -> &'static str { + "REFSET" + } + fn prompt(&self) -> String { + if self.gathering { + t!("Select objects:").into_owned() + } else { + t!("Enter an option [Add/Remove] :").into_owned() + } + } + fn options(&self) -> Vec { + if self.gathering { + Vec::new() + } else { + vec![CmdOption::new("Add", "A"), CmdOption::new("Remove", "R")] + } + } + fn wants_text_input(&self) -> bool { + !self.gathering + } + fn is_selection_gathering(&self) -> bool { + self.gathering + } + fn on_selection_complete(&mut self, handles: Vec) -> CmdResult { + self.selected = handles; + CmdResult::NeedPoint + } + fn on_text_input(&mut self, text: &str) -> Option { + if self.gathering { + return None; + } + let word = text.trim().to_ascii_uppercase(); + if word.is_empty() { + return None; + } + Some(if "ADD".starts_with(&word) { + self.gathering = true; + CmdResult::NeedPoint + } else if "REMOVE".starts_with(&word) { + self.add = false; + self.gathering = true; + CmdResult::NeedPoint + } else { + CmdResult::ReportError(t!("Invalid option keyword.").into_owned()) + }) + } fn on_point(&mut self, _pt: DVec3) -> CmdResult { CmdResult::NeedPoint } fn on_enter(&mut self) -> CmdResult { - CmdResult::Cancel + if !self.gathering { + self.gathering = true; + return CmdResult::NeedPoint; + } + if self.selected.is_empty() { + return CmdResult::Cancel; + } + CmdResult::Relaunch( + format!("REFSET_APPLY:{}", if self.add { "ADD" } else { "REMOVE" }), + std::mem::take(&mut self.selected), + ) } } // ── REFCLOSE command ─────────────────────────────────────────────────────── -/// Step 4: prompt for SAVE or DISCARD. +/// `Enter option [Save/Discard reference changes] :` pub struct RefCloseCommand; impl RefCloseCommand { @@ -120,25 +562,30 @@ impl CadCommand for RefCloseCommand { "REFCLOSE" } fn prompt(&self) -> String { - t!("REFCLOSE [Save/Discard] :").into_owned() + t!("Enter option [Save/Discard reference changes] :").into_owned() + } + fn options(&self) -> Vec { + vec![CmdOption::new("Save", "S"), CmdOption::new("Discard", "D")] } 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 { + if t.is_empty() { + return None; + } + if "SAVE".starts_with(&t) { Some(CmdResult::Relaunch("REFCLOSE_SAVE".into(), vec![])) - } else if discard { + } else if "DISCARD".starts_with(&t) { Some(CmdResult::Relaunch("REFCLOSE_DISCARD".into(), vec![])) } else { - Some(CmdResult::NeedPoint) // re-prompt + Some(CmdResult::ReportError( + t!("Invalid option keyword.").into_owned(), + )) } } fn on_enter(&mut self) -> CmdResult { - // Default: SAVE CmdResult::Relaunch("REFCLOSE_SAVE".into(), vec![]) } fn on_point(&mut self, _pt: DVec3) -> CmdResult { @@ -148,23 +595,48 @@ impl CadCommand for RefCloseCommand { // ── Geometry helpers ─────────────────────────────────────────────────────── -/// Apply the INSERT's forward transform to a block-local entity so it -/// appears at its correct world-space position. The full affine handles -/// OCS, rotation and non-uniform / mirrored scale at once. +/// Inverse of an affine placement (rotation / scale / mirror and translation), +/// or None when it is singular. +pub fn inverse_placement(t: &codec::types::Transform) -> Option { + use codec::types::{Matrix3, Matrix4}; + let m = &t.matrix.m; + let linear = Matrix3 { + m: [ + [m[0][0], m[0][1], m[0][2]], + [m[1][0], m[1][1], m[1][2]], + [m[2][0], m[2][1], m[2][2]], + ], + } + .inverse()?; + let r = linear.m; + let tr = [m[0][3], m[1][3], m[2][3]]; + let mut out = Matrix4::identity(); + for row in 0..3 { + for col in 0..3 { + out.m[row][col] = r[row][col]; + } + out.m[row][3] = -(r[row][0] * tr[0] + r[row][1] * tr[1] + r[row][2] * tr[2]); + } + Some(codec::types::Transform::from_matrix(out)) +} + + +/// Apply the session's forward transform to a definition-local entity so it +/// appears at its correct world-space position. pub fn apply_insert_transform(entity: &mut EntityType, session: &RefEditSession) { entity.as_entity_mut().apply_transform(&session.forward); } -/// Apply the INSERT's inverse transform to a world-space entity to bring it -/// back to block-local coordinates. +/// Apply the session's inverse transform to a world-space entity to bring it +/// back to definition-local coordinates. pub fn apply_insert_inverse_transform(entity: &mut EntityType, session: &RefEditSession) { entity.as_entity_mut().apply_transform(&session.inverse); } // ── Autocomplete registry ───────────────────────────────── inventory::submit!(crate::command::CommandRegistration { - names: &["REFCLOSE"] -}); // RefCloseCommand + names: &["REFCLOSE", "REFSET", "-REFEDIT"] +}); inventory::submit!(crate::command::CommandRegistration { names: &["REFEDIT"] }); // RefEditPickCommand diff --git a/src/scene/cache/block_cache.rs b/src/scene/cache/block_cache.rs index 3982957f4..2b8e3fba4 100644 --- a/src/scene/cache/block_cache.rs +++ b/src/scene/cache/block_cache.rs @@ -1531,6 +1531,29 @@ pub(crate) fn fade_toward_bg(color: [f32; 4], bg: [f32; 4]) -> [f32; 4] { ] } +/// XFADECTL: how far objects outside the REFEDIT working set fade toward the +/// background, in percent (0–90, default 70). +static REFEDIT_FADE: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(70); + +pub fn refedit_fade_ctl() -> i32 { + REFEDIT_FADE.load(std::sync::atomic::Ordering::Relaxed) +} + +pub fn set_refedit_fade_ctl(value: i32) { + REFEDIT_FADE.store(value.clamp(0, 90), std::sync::atomic::Ordering::Relaxed); +} + +/// A colour outside the REFEDIT working set, faded by XFADECTL. +pub(crate) fn refedit_fade(color: [f32; 4], bg: [f32; 4]) -> [f32; 4] { + let t = refedit_fade_ctl().clamp(0, 90) as f32 / 100.0; + [ + color[0] * (1.0 - t) + bg[0] * t, + color[1] * (1.0 - t) + bg[1] * t, + color[2] * (1.0 - t) + bg[2] * t, + color[3], + ] +} + /// XDWGFADECTL: how far referenced drawings fade toward the background, in /// percent (0–90); zero or negative shows them unfaded. static XREF_FADE: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(50); diff --git a/src/scene/mod.rs b/src/scene/mod.rs index b49d74dc7..d0f0dd210 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -2147,6 +2147,8 @@ pub struct Scene { /// edited geometry stands out while the surrounding drawing stays visible /// for context. `None` = not editing. (#136) pub refedit_keep: Option>, + /// REFEDIT locks the objects outside the working set (`refedit_keep`). + pub refedit_lock: bool, /// COUNT mode colours: counted references in COUNTCOLOR, overlapping /// duplicates in COUNTERRORCOLOR, everything else faded. `None` outside /// count mode. @@ -2775,6 +2777,7 @@ impl Scene { preview_hidden: HashSet::default(), command_preview_hidden: HashSet::default(), refedit_keep: None, + refedit_lock: false, count_display: None, hover_highlight: None, constraint_hover_highlights: HashSet::default(), @@ -4337,7 +4340,7 @@ impl Scene { ); if let Some(keep) = &self.refedit_keep { if !keep.contains(&h) { - material.diffuse = crate::scene::cache::block_cache::fade_toward_bg( + material.diffuse = crate::scene::cache::block_cache::refedit_fade( material.diffuse, bg, ); @@ -4393,7 +4396,7 @@ impl Scene { .is_some_and(|keep| !keep.contains(&handle)) { material.diffuse = - crate::scene::cache::block_cache::fade_toward_bg(material.diffuse, bg); + crate::scene::cache::block_cache::refedit_fade(material.diffuse, bg); } if self.meshes.contains_key(&handle) { material.diffuse = self.count_color(handle, material.diffuse, bg); @@ -4538,7 +4541,7 @@ impl Scene { let keep_bright = Self::handle_from_wire_name(&w.name).is_some_and(|h| keep.contains(&h)); if !keep_bright { - w.color = crate::scene::cache::block_cache::fade_toward_bg(w.color, bg); + w.color = crate::scene::cache::block_cache::refedit_fade(w.color, bg); } } } @@ -9389,6 +9392,13 @@ impl Scene { .layers .get(&entity.common().layer) .is_some_and(|l| l.is_locked()) + // REFEDIT with "Lock objects not in working set": only the + // working set can be edited. + || (self.refedit_lock + && self + .refedit_keep + .as_ref() + .is_some_and(|keep| !keep.contains(&handle))) } /// The name of the locked layer `handle` sits on, if any (for messages). @@ -9682,7 +9692,7 @@ impl Scene { }; if let Some(keep) = &self.refedit_keep { if !keep.contains(&h) { - c = crate::scene::cache::block_cache::fade_toward_bg(c, self.current_bg()); + c = crate::scene::cache::block_cache::refedit_fade(c, self.current_bg()); } } Some(c)