forked from 1b-agent/opencadstudio-upstream
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>
671 lines
24 KiB
Rust
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
|