// REFEDIT / -REFEDIT / REFSET / REFCLOSE — in-place reference editing. // // Workflow: // 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, CmdOption, CmdResult}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; /// Right-edge side-toolbar buttons that move objects into or out of the /// working set while a REFEDIT session is active (REFSET Add / Remove). pub fn refedit_set_tools() -> Vec { vec![ ToolDef { id: "REFSET_ADD", label: "Add to Working Set", icon: IconKind::Svg(include_bytes!("../../../../assets/icons/refset_add.svg")), event: ModuleEvent::Command("REFSET_ADD".to_string()), }, ToolDef { id: "REFSET_REMOVE", label: "Remove from Working Set", icon: IconKind::Svg(include_bytes!("../../../../assets/icons/refset_remove.svg")), event: ModuleEvent::Command("REFSET_REMOVE".to_string()), }, ] } /// Right-edge side-toolbar buttons shown while a REFEDIT session is active: /// save the in-place block edit, or discard it. (#136) pub fn refedit_tools() -> Vec { vec![ ToolDef { id: "REFCLOSE_SAVE", label: "Save Block Edit", icon: IconKind::Svg(include_bytes!("../../../../assets/icons/mt_ok.svg")), event: ModuleEvent::Command("REFCLOSE_SAVE".to_string()), }, ToolDef { id: "REFCLOSE_DISCARD", label: "Discard Block Edit", icon: IconKind::Svg(include_bytes!("../../../../assets/icons/mt_cancel.svg")), event: ModuleEvent::Command("REFCLOSE_DISCARD".to_string()), }, ] } /// 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 definition being edited (REFEDITNAME). pub block_name: String, /// Handle of the block record (owns the block entities). pub br_handle: Handle, /// The working copies of the extracted definition objects. pub temp_handles: Vec, /// 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 → 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, /// offset, …) belongs to the working set, so Save folds it into the block /// 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, } 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 } } // ── 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 { /// `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 { self.name } fn prompt(&self) -> String { t!("Select reference:").into_owned() } fn needs_entity_pick(&self) -> bool { true } fn on_entity_pick(&mut self, handle: Handle, pt: DVec3) -> CmdResult { if handle.is_null() { return CmdResult::ReportError(t!("Reference not found.").into_owned()); } 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(), } } /// Straight to `Select objects:` for Add (`add`) or Remove. pub fn selecting(add: bool) -> Self { Self { add, gathering: true, 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 { 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 ─────────────────────────────────────────────────────── /// `Enter option [Save/Discard reference changes] :` pub struct RefCloseCommand; impl RefCloseCommand { pub fn new() -> Self { Self } } impl CadCommand for RefCloseCommand { fn name(&self) -> &'static str { "REFCLOSE" } fn prompt(&self) -> String { 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(); if t.is_empty() { return None; } if "SAVE".starts_with(&t) { Some(CmdResult::Relaunch("REFCLOSE_SAVE".into(), vec![])) } else if "DISCARD".starts_with(&t) { Some(CmdResult::Relaunch("REFCLOSE_DISCARD".into(), vec![])) } else { Some(CmdResult::ReportError( t!("Invalid option keyword.").into_owned(), )) } } fn on_enter(&mut self) -> CmdResult { CmdResult::Relaunch("REFCLOSE_SAVE".into(), vec![]) } fn on_point(&mut self, _pt: DVec3) -> CmdResult { CmdResult::NeedPoint } } // ── Geometry helpers ─────────────────────────────────────────────────────── /// 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 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", "REFSET", "-REFEDIT"] }); inventory::submit!(crate::command::CommandRegistration { names: &["REFEDIT"] }); // RefEditPickCommand