REFEDIT: reference prompts, -REFEDIT, REFSET, nested references, xref write-back

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ramox81andClaude Opus 5.5 committed 2026-10-13 15:52:26 +03:00
1 parent b1d4c9bd9b
commit 6784f89993
16 files changed
+1296 -380

No files matched your search

+519 -47
View File
@@ -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<ToolDef> {
]
}
/// 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<Handle>,
// ── 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<Handle>,
/// Host objects moved into the working set with REFSET Add.
pub added: HashSet<Handle>,
/// Working-set objects moved out to the host with REFSET Remove.
pub removed: HashSet<Handle>,
/// 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<String>,
}
// ── 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<Vec<[f64; 3]>>)>;
/// 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<Vec<[f64; 3]>> {
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<f64> {
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<DVec3>,
) -> Vec<ChainLevel> {
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<Handle>,
}
impl RefEditPickCommand {
pub fn new() -> Self {
Self
/// `command_line` selects -REFEDIT (prompts) over REFEDIT.
pub fn new(command_line: bool, references: HashSet<Handle>) -> 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:<handle>") 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] <Next>:` → `Enter object selection method
/// [All/Nested] <All>:` (→ `Select nested objects:`) → `Display attribute
/// definitions [Yes/No] <No>:`, then the session starts.
pub struct RefEditOptionsCommand {
top: Handle,
chain: Vec<ChainLevel>,
hits: Vec<LevelHits>,
level: usize,
step: OptionStep,
nested: Vec<Handle>,
}
impl RefEditOptionsCommand {
pub fn new(top: Handle, chain: Vec<ChainLevel>, hits: Vec<LevelHits>) -> 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<String> = self.chain[1..=self.level]
.iter()
.map(|l| l.insert.value().to_string())
.collect();
let nested: Vec<String> = 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] <Next>:").into_owned(),
OptionStep::Method => {
t!("Enter object selection method [All/Nested] <All>:").into_owned()
}
OptionStep::Nested => t!("Select nested objects:").into_owned(),
OptionStep::Attributes => {
t!("Display attribute definitions [Yes/No] <No>:").into_owned()
}
}
}
fn options(&self) -> Vec<CmdOption> {
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<CmdResult> {
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] <Add>:` then `Select objects:`.
pub struct RefSetCommand {
add: bool,
gathering: bool,
selected: Vec<Handle>,
}
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] <Add>:").into_owned()
}
}
fn options(&self) -> Vec<CmdOption> {
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<Handle>) -> CmdResult {
self.selected = handles;
CmdResult::NeedPoint
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
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] <Save>:`
pub struct RefCloseCommand;
impl RefCloseCommand {
@@ -120,25 +562,30 @@ impl CadCommand for RefCloseCommand {
"REFCLOSE"
}
fn prompt(&self) -> String {
t!("REFCLOSE [Save/Discard] <Save>:").into_owned()
t!("Enter option [Save/Discard reference changes] <Save>:").into_owned()
}
fn options(&self) -> Vec<CmdOption> {
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<CmdResult> {
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<codec::types::Transform> {
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