Files
OneCAD-Draft/src/modules/draw/modify/refedit.rs
T
ramox81andClaude Opus 5.5 74d65c26be REFEDIT session toolbar: definition name, working set count, REFSET
The right-edge toolbar of a REFEDIT session now shows the definition
being edited (REFEDITNAME) as an accent pill with the number of objects
in the working set, Add to / Remove from Working Set buttons with their
own icons (REFSET Add / Remove, straight to object selection), and
Save / Discard in a separate group below a rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:51:22 +03:00

671 lines
24 KiB
Rust

// 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<ToolDef> {
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<ToolDef> {
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<Handle>,
/// 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 → 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<String>,
}
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<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 {
/// `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 {
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] <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(),
}
}
/// 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] <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 {
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] <Save>:`
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] <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();
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<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 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