forked from 1b-agent/opencadstudio-upstream
Applying to one target used to end the command. The apply now clears the consumed selection and keeps the command in its target phase, so properties can be painted onto object after object; Enter or Escape finishes (prompt updated accordingly). Co-Authored-By: Claude Fable 5 <[email protected]>
2711 lines
129 KiB
Rust
2711 lines
129 KiB
Rust
use super::{Message, OpenCADStudio};
|
|
use crate::command::{CmdResult, StepInput};
|
|
use acadrust::Handle;
|
|
use iced::Task;
|
|
|
|
impl OpenCADStudio {
|
|
/// Drive the active command's step machine with one [`StepInput`], then
|
|
/// apply the result. This is the single entry point every input source
|
|
/// (command line, headless, dynamic input, plugin API, viewport) funnels
|
|
/// through, so the routing from input → `on_*` method → `apply_cmd_result`
|
|
/// lives in exactly one place. No-op when no command is active.
|
|
pub(super) fn feed_command(&mut self, input: StepInput) -> Task<Message> {
|
|
// Selection keywords (P / PREVIOUS, L / LAST) consume the token
|
|
// before it reaches the command (#426).
|
|
if let StepInput::Text(s) = &input {
|
|
let kw_input = s.clone();
|
|
if let Some(task) = self.try_selection_keyword(&kw_input) {
|
|
return task;
|
|
}
|
|
}
|
|
let i = self.active_tab;
|
|
let ctrl = self.ctrl_down;
|
|
let result: Option<CmdResult> = {
|
|
let Some(cmd) = self.tabs[i].active_cmd.as_mut() else {
|
|
return Task::none();
|
|
};
|
|
cmd.set_ctrl(ctrl);
|
|
match input {
|
|
StepInput::Point(p) => Some(cmd.on_point(p)),
|
|
StepInput::Text(s) => cmd.on_text_input(&s),
|
|
StepInput::EntityPick(h, p) => Some(cmd.on_entity_pick(h, p)),
|
|
StepInput::StructurePick(h, p) => Some(cmd.on_structure_pick(h, p)),
|
|
StepInput::SelectionComplete(hs) => Some(cmd.on_selection_complete(hs)),
|
|
StepInput::Tangent(o, p) => Some(cmd.on_tangent_point(o, p)),
|
|
StepInput::EditorClosed(c) => Some(cmd.on_editor_closed(c)),
|
|
StepInput::Enter => Some(cmd.on_enter()),
|
|
StepInput::Escape => Some(cmd.on_escape()),
|
|
}
|
|
};
|
|
match result {
|
|
Some(r) => self.apply_cmd_result(r),
|
|
None => Task::none(),
|
|
}
|
|
}
|
|
|
|
/// Run one whole command-line string. A single word or an inline-argument
|
|
/// command (`PDMODE 3`, `LAYER Walls`, `UCS Z 90` pasted as one line)
|
|
/// dispatches as-is; for a multi-token line whose first word starts an
|
|
/// interactive tool (`LINE 0,0 10,10`) the first word starts the tool and the
|
|
/// remaining tokens are fed as points / option keywords, then the command is
|
|
/// terminated as if Enter were pressed. Shared by the GUI command line and
|
|
/// the headless automation feeder so both behave identically.
|
|
pub(super) fn run_command_line(&mut self, cmd: &str) -> Task<Message> {
|
|
let i = self.active_tab;
|
|
let tokens: Vec<&str> = cmd.split_whitespace().collect();
|
|
if tokens.len() <= 1 {
|
|
return self.dispatch_command(cmd);
|
|
}
|
|
// Plugin commands parse their own inline arguments from the whole line
|
|
// (e.g. `HC_PIPE 2B 2C 1.25 0.013`), so offer the full command to plugin
|
|
// dispatch first. A built-in interactive tool matches only its bare name
|
|
// (`LINE`), so the full line is not a plugin command and falls through to
|
|
// the first-word + fed-tokens path below. (#162)
|
|
if crate::plugin::try_dispatch(self, i, cmd) {
|
|
let toks: Vec<String> = tokens.iter().map(|s| s.to_string()).collect();
|
|
self.finish_active_command(&toks);
|
|
return Task::none();
|
|
}
|
|
let _ = self.dispatch_command(tokens[0]);
|
|
if self.tabs[i].active_cmd.is_none() {
|
|
// Not an interactive tool — an inline-argument command (`PDMODE 3`).
|
|
return self.dispatch_command(cmd);
|
|
}
|
|
let toks: Vec<String> = tokens.iter().map(|s| s.to_string()).collect();
|
|
self.finish_active_command(&toks);
|
|
Task::none()
|
|
}
|
|
|
|
/// Feed `tokens[1..]` to the active interactive command as points / option
|
|
/// keywords, then terminate it as if Enter were pressed. No-op when no
|
|
/// command is active.
|
|
pub(super) fn finish_active_command(&mut self, tokens: &[String]) {
|
|
let i = self.active_tab;
|
|
if self.tabs[i].active_cmd.is_none() {
|
|
return;
|
|
}
|
|
self.last_point = None;
|
|
for tok in &tokens[1..] {
|
|
if self.tabs[i].active_cmd.is_none() {
|
|
break;
|
|
}
|
|
self.feed_active_cmd(tok);
|
|
}
|
|
let _ = self.feed_command(StepInput::Enter);
|
|
}
|
|
|
|
/// Classify one typed token into a [`StepInput`] and route it through the
|
|
/// shared [`Self::feed_command`]. An object-pick step takes a hex handle; a
|
|
/// coordinate is parsed (and, like the GUI command line, interpreted in the
|
|
/// active UCS); anything else is an option keyword / value. Used by both the
|
|
/// GUI command line and headless automation.
|
|
/// Standard selection keywords at a "Select objects:" prompt (#426):
|
|
/// P / PREVIOUS re-selects the set the last command worked on, and
|
|
/// L / LAST selects the most recently created object in the current
|
|
/// space. Returns `Some` when the token was consumed as a keyword.
|
|
/// Handled centrally so every gathering command gets them for free.
|
|
pub(super) fn try_selection_keyword(&mut self, text: &str) -> Option<Task<Message>> {
|
|
let i = self.active_tab;
|
|
let gathering = self.tabs[i]
|
|
.active_cmd
|
|
.as_ref()
|
|
.map_or(false, |c| c.is_selection_gathering());
|
|
if !gathering {
|
|
return None;
|
|
}
|
|
let kw = text.trim().to_ascii_uppercase();
|
|
let add: Vec<Handle> = match kw.as_str() {
|
|
"P" | "PREVIOUS" => self.tabs[i]
|
|
.prev_selection
|
|
.iter()
|
|
.copied()
|
|
.filter(|&h| {
|
|
self.tabs[i].scene.document.get_entity(h).is_some()
|
|
&& !self.tabs[i].scene.is_layer_locked(h)
|
|
})
|
|
.collect(),
|
|
// Highest handle among the selectable wires of the current space —
|
|
// handles are handed out monotonically, so that is the most
|
|
// recently created object.
|
|
"L" | "LAST" => self.tabs[i]
|
|
.scene
|
|
.entity_wires()
|
|
.iter()
|
|
.filter_map(|w| crate::scene::Scene::handle_from_wire_name(&w.name))
|
|
.filter(|&h| !self.tabs[i].scene.is_layer_locked(h))
|
|
.max_by_key(|h| h.value())
|
|
.into_iter()
|
|
.collect(),
|
|
_ => return None,
|
|
};
|
|
if add.is_empty() {
|
|
self.command_line.push_info(match kw.as_str() {
|
|
"P" | "PREVIOUS" => "No previous selection set.",
|
|
_ => "No last object.",
|
|
});
|
|
return Some(Task::none());
|
|
}
|
|
let count = add.len();
|
|
for h in add {
|
|
self.tabs[i].scene.select_entity(h, false);
|
|
}
|
|
self.command_line
|
|
.push_info(&format!("{count} object(s) added to selection."));
|
|
self.refresh_properties();
|
|
let handles: Vec<Handle> = self.tabs[i]
|
|
.scene
|
|
.selected_entities()
|
|
.into_iter()
|
|
.map(|(h, _)| h)
|
|
.collect();
|
|
let result = self.tabs[i]
|
|
.active_cmd
|
|
.as_mut()
|
|
.map(|cmd| cmd.on_selection_complete(handles));
|
|
Some(match result {
|
|
Some(r) => self.apply_cmd_result(r),
|
|
None => Task::none(),
|
|
})
|
|
}
|
|
|
|
pub(super) fn feed_active_cmd(&mut self, token: &str) {
|
|
let i = self.active_tab;
|
|
// Object-pick step: the token is a handle (as returned by `query`).
|
|
if self.tabs[i]
|
|
.active_cmd
|
|
.as_ref()
|
|
.is_some_and(|c| c.needs_entity_pick())
|
|
{
|
|
if let Ok(v) = u64::from_str_radix(token.trim_start_matches("0x"), 16) {
|
|
let handle = Handle::new(v);
|
|
let pt = self.tabs[i]
|
|
.scene
|
|
.document
|
|
.get_entity(handle)
|
|
.map(|e| {
|
|
let bb = e.as_entity().bounding_box();
|
|
glam::Vec3::new(
|
|
((bb.min.x + bb.max.x) * 0.5) as f32,
|
|
((bb.min.y + bb.max.y) * 0.5) as f32,
|
|
0.0,
|
|
)
|
|
})
|
|
.unwrap_or(glam::Vec3::ZERO);
|
|
let _ = self.feed_command(StepInput::EntityPick(handle, pt.as_dvec3()));
|
|
}
|
|
return;
|
|
}
|
|
if let Some((coord, kind)) = super::helpers::parse_coord(token) {
|
|
// Match the GUI command line: typed coordinates are in the active
|
|
// UCS (relative offsets are rotated by the UCS axes), so a multi-
|
|
// token `LINE 0,0 10,10` under a rotated UCS lands correctly.
|
|
let ucs = self.tabs[i].active_ucs.clone();
|
|
let wcs = match (matches!(kind, super::helpers::CoordKind::Relative), self.last_point) {
|
|
(true, Some(base)) => {
|
|
base + match &ucs {
|
|
Some(u) => super::helpers::ucs_rotate_vec(coord, u),
|
|
None => coord,
|
|
}
|
|
}
|
|
_ => match &ucs {
|
|
Some(u) => super::helpers::ucs_to_wcs(coord, u),
|
|
None => coord,
|
|
},
|
|
};
|
|
self.last_point = Some(wcs);
|
|
self.push_ucs_to_cmd(i);
|
|
let _ = self.feed_command(StepInput::Point(wcs));
|
|
} else {
|
|
let _ = self.feed_command(StepInput::Text(token.to_string()));
|
|
}
|
|
}
|
|
|
|
/// Applies one command result, then — when that result ended the active
|
|
/// command — drops the selection. Editing tools (MOVE, COPY, ROTATE, …) and
|
|
/// every other interactive command leave nothing selected once they finish,
|
|
/// so a follow-up edit doesn't silently reuse the previous working set.
|
|
///
|
|
/// `Relaunch`/`Dispatch` are excepted: they end the front-end command only
|
|
/// to immediately start another and hand it a deliberate selection (the
|
|
/// pick-first selector relaunching MOVE on the picked set works this way).
|
|
/// Pure-selection commands (SELECTALL, QSELECT, …) run without an active
|
|
/// command, so `was_active` is false and their selection is preserved.
|
|
pub(super) fn apply_cmd_result(&mut self, result: CmdResult) -> Task<Message> {
|
|
let was_active = self.tabs[self.active_tab].active_cmd.is_some();
|
|
let preserve_selection =
|
|
matches!(result, CmdResult::Relaunch(..) | CmdResult::Dispatch(..));
|
|
let task = self.apply_cmd_result_inner(result);
|
|
let i = self.active_tab;
|
|
if was_active
|
|
&& !preserve_selection
|
|
&& self.tabs[i].active_cmd.is_none()
|
|
&& !self.tabs[i].scene.selected.is_empty()
|
|
{
|
|
// Remember the working set for the "Previous" selection keyword
|
|
// (#426) before dropping it.
|
|
self.tabs[i].prev_selection =
|
|
self.tabs[i].scene.selected.iter().copied().collect();
|
|
self.tabs[i].scene.deselect_all();
|
|
self.refresh_properties();
|
|
}
|
|
// A command just ended (any terminal result) — if it was the draw
|
|
// command an ADDSELECTED launched, revert the template-property override
|
|
// so CLAYER / CECOLOR / … are left unchanged (#239). No-op otherwise.
|
|
if was_active && self.tabs[i].active_cmd.is_none() {
|
|
self.restore_add_selected_defaults();
|
|
}
|
|
task
|
|
}
|
|
|
|
fn apply_cmd_result_inner(&mut self, result: CmdResult) -> Task<Message> {
|
|
let i = self.active_tab;
|
|
match result {
|
|
CmdResult::NeedPoint => {
|
|
// ATTEDIT finished its entity pick: hand the chosen block off to
|
|
// the attribute editor dialog and end the command (open_attribute
|
|
// _editor reports "no attributes" / "select a block" as needed).
|
|
let attedit_handle = self.tabs[i]
|
|
.active_cmd
|
|
.as_ref()
|
|
.and_then(|c| c.attedit_pending_handle());
|
|
if let Some(ins_handle) = attedit_handle {
|
|
self.tabs[i].active_cmd = None;
|
|
self.open_attribute_editor(ins_handle);
|
|
return Task::none();
|
|
}
|
|
let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt());
|
|
if let Some(p) = prompt {
|
|
self.command_line.push_info(&p);
|
|
}
|
|
// The command may have advanced to a step with a different
|
|
// dynamic-input shape (e.g. FILLET object-pick → radius entry).
|
|
// Rebuild the fields now so the matching box appears immediately
|
|
// and typed digits land in it rather than the command line,
|
|
// instead of waiting for the next cursor move to resync.
|
|
self.sync_dyn_fields();
|
|
}
|
|
CmdResult::Preview(wire) => {
|
|
self.tabs[i].scene.set_preview_wires(vec![wire]);
|
|
let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt());
|
|
if let Some(p) = prompt {
|
|
self.command_line.push_info(&p);
|
|
}
|
|
}
|
|
CmdResult::InterimWire(wire) => {
|
|
self.tabs[i].scene.set_interim_wire(wire);
|
|
let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt());
|
|
if let Some(p) = prompt {
|
|
self.command_line.push_info(&p);
|
|
}
|
|
}
|
|
CmdResult::CommitEntity(entity) => {
|
|
// A line/arc drawn by a repeating command advances the ARC_CONT
|
|
// continuation anchor, so ending one run and launching another
|
|
// keeps continuing from the last segment (mirrors the
|
|
// CommitAndExit arm). Non-continuable repeats (point/ray)
|
|
// leave the anchor untouched. (#327)
|
|
if matches!(
|
|
entity,
|
|
acadrust::EntityType::Line(_) | acadrust::EntityType::Arc(_)
|
|
) {
|
|
self.update_cont_anchor(&entity);
|
|
}
|
|
let label = self.history_label_from_active_cmd(i, "ENTITY");
|
|
// A plain drawable added on an existing layer touches only the
|
|
// one new entity; a block/image/dimension/viewport add also
|
|
// mutates layers/objects/blocks → keep the full snapshot.
|
|
let delta_safe = self.delta_add_safe(i, &entity);
|
|
let pending = self.begin_undo(i, label, 1, delta_safe);
|
|
self.commit_entity(entity);
|
|
self.tabs[i].dirty = true;
|
|
let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt());
|
|
if let Some(p) = prompt {
|
|
self.command_line.push_info(&p);
|
|
}
|
|
if let Some(pd) = pending {
|
|
self.commit_undo_delta(i, pd);
|
|
}
|
|
}
|
|
CmdResult::TransformSelected(handles, transform) => {
|
|
let label = self.history_label_from_active_cmd(i, "MOVE");
|
|
// A move/rotate/scale/mirror mutates only the selected entities
|
|
// (and their baked dimension sub-entities) through
|
|
// transform_entities — always delta-safe.
|
|
let pending = self.begin_undo(i, label, handles.len(), true);
|
|
self.tabs[i].scene.transform_entities(&handles, &transform);
|
|
// ACIS solids render from a cached mesh, so a move/rotate/
|
|
// scale/mirror needs the mesh re-tessellated from the now-moved
|
|
// body — wire re-tessellation alone leaves the solid drawn at
|
|
// its old spot. (#135)
|
|
if self.tabs[i].scene.any_solid(&handles) {
|
|
self.tabs[i].scene.populate_meshes_from_document();
|
|
}
|
|
self.tabs[i].dirty = true;
|
|
self.tabs[i].scene.clear_preview_wire();
|
|
self.tabs[i].active_cmd = None;
|
|
self.tabs[i].snap_result = None;
|
|
self.restore_pre_cmd_tangent();
|
|
self.refresh_properties();
|
|
if let Some(p) = pending {
|
|
self.commit_undo_delta(i, p);
|
|
}
|
|
}
|
|
CmdResult::CopySelected(handles, transform) => {
|
|
let label = self.history_label_from_active_cmd(i, "COPY");
|
|
// Copying a dimension clones a *D block record, so gate delta on
|
|
// the selection being dimension-free.
|
|
let delta_safe = self.delta_copy_safe(i, &handles);
|
|
let pending = self.begin_undo(i, label, handles.len(), delta_safe);
|
|
let new_handles = self.tabs[i].scene.copy_entities(&handles, &transform);
|
|
if self.tabs[i].scene.any_solid(&new_handles) {
|
|
self.tabs[i].scene.populate_meshes_from_document();
|
|
}
|
|
self.tabs[i].dirty = true;
|
|
self.tabs[i].scene.deselect_all();
|
|
for h in new_handles {
|
|
self.tabs[i].scene.select_entity(h, false);
|
|
}
|
|
self.tabs[i].scene.clear_preview_wire();
|
|
let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt());
|
|
if let Some(p) = prompt {
|
|
self.command_line.push_info(&p);
|
|
}
|
|
self.refresh_properties();
|
|
if let Some(pd) = pending {
|
|
self.commit_undo_delta(i, pd);
|
|
}
|
|
}
|
|
CmdResult::CommitAndExit(entity) => {
|
|
// For XATTACH: ensure the xref block definition exists before
|
|
// committing the INSERT entity that references it.
|
|
// Extract path early to avoid borrow conflicts.
|
|
let xattach_path: Option<String> = {
|
|
let tab = &self.tabs[i];
|
|
if let Some(cmd) = tab.active_cmd.as_ref() {
|
|
if cmd.name() == "XATTACH" {
|
|
cmd.xattach_path()
|
|
} else {
|
|
None
|
|
}
|
|
} else {
|
|
None
|
|
}
|
|
};
|
|
if let Some(path) = xattach_path {
|
|
crate::modules::insert::xattach::prepare_xref_block(
|
|
&mut self.tabs[i].scene,
|
|
&path,
|
|
);
|
|
// Resolving the xref merged its layer / linetype tables
|
|
// into the document — mirror them into the Layers panel
|
|
// and ribbon dropdowns now, not on the next reopen (#407).
|
|
self.refresh_layer_panel();
|
|
}
|
|
// Record where this draw ended so ARC_CONT can continue from it
|
|
// (before `entity` is moved into commit_entity).
|
|
self.update_cont_anchor(&entity);
|
|
let label = self.history_label_from_active_cmd(i, "ENTITY");
|
|
let delta_safe = self.delta_add_safe(i, &entity);
|
|
let pending = self.begin_undo(i, label, 1, delta_safe);
|
|
self.commit_entity(entity);
|
|
self.tabs[i].dirty = true;
|
|
self.tabs[i].scene.clear_preview_wire();
|
|
self.tabs[i].active_cmd = None;
|
|
self.tabs[i].snap_result = None;
|
|
self.restore_pre_cmd_tangent();
|
|
if let Some(pd) = pending {
|
|
self.commit_undo_delta(i, pd);
|
|
}
|
|
}
|
|
CmdResult::CommitSolid { entity, solid } => {
|
|
let label = self.history_label_from_active_cmd(i, "SOLID");
|
|
self.push_undo_snapshot(i, label);
|
|
self.add_solid_model(entity, *solid);
|
|
self.tabs[i].dirty = true;
|
|
self.tabs[i].scene.clear_preview_wire();
|
|
self.tabs[i].active_cmd = None;
|
|
self.tabs[i].snap_result = None;
|
|
self.restore_pre_cmd_tangent();
|
|
}
|
|
CmdResult::CommitAndEditText(entity) => {
|
|
let label = self.history_label_from_active_cmd(i, "ENTITY");
|
|
let delta_safe = self.delta_add_safe(i, &entity);
|
|
let pending = self.begin_undo(i, label, 1, delta_safe);
|
|
let handle = self.commit_entity_handle(entity);
|
|
self.tabs[i].dirty = true;
|
|
self.tabs[i].scene.clear_preview_wire();
|
|
self.tabs[i].active_cmd = None;
|
|
self.tabs[i].snap_result = None;
|
|
self.restore_pre_cmd_tangent();
|
|
self.ribbon.deactivate_tool();
|
|
if let Some(pd) = pending {
|
|
self.commit_undo_delta(i, pd);
|
|
}
|
|
if let Some(h) = handle {
|
|
return self.begin_text_edit(h);
|
|
}
|
|
}
|
|
CmdResult::CommitManyAndEditText {
|
|
entities,
|
|
edit_index,
|
|
} => {
|
|
let label = self.history_label_from_active_cmd(i, "ENTITY");
|
|
self.push_undo_snapshot(i, label);
|
|
let mut edit_handle = None;
|
|
let mut leader_handle = None;
|
|
for (idx, entity) in entities.into_iter().enumerate() {
|
|
let is_leader = matches!(entity, acadrust::EntityType::Leader(_));
|
|
let h = self.commit_entity_handle(entity);
|
|
if idx == edit_index {
|
|
edit_handle = h;
|
|
}
|
|
if is_leader {
|
|
leader_handle = h;
|
|
}
|
|
}
|
|
// Link the leader to its annotation so the pair edits as a unit
|
|
// (double-click on the leader resolves to the text entity).
|
|
if let (Some(lh), Some(ah)) = (leader_handle, edit_handle) {
|
|
if let Some(acadrust::EntityType::Leader(l)) =
|
|
self.tabs[i].scene.document.get_entity_mut(lh)
|
|
{
|
|
l.annotation_handle = ah;
|
|
}
|
|
}
|
|
self.tabs[i].dirty = true;
|
|
self.tabs[i].scene.clear_preview_wire();
|
|
self.tabs[i].active_cmd = None;
|
|
self.tabs[i].snap_result = None;
|
|
self.restore_pre_cmd_tangent();
|
|
self.ribbon.deactivate_tool();
|
|
if let Some(h) = edit_handle {
|
|
return self.begin_text_edit(h);
|
|
}
|
|
}
|
|
CmdResult::CreateBlock {
|
|
handles,
|
|
name,
|
|
base,
|
|
} => {
|
|
self.push_undo_snapshot(i, "BLOCK");
|
|
match self.tabs[i]
|
|
.scene
|
|
.create_block_from_entities(&handles, &name, base)
|
|
{
|
|
Ok(insert_handle) => {
|
|
self.tabs[i].dirty = true;
|
|
self.tabs[i].scene.deselect_all();
|
|
if !insert_handle.is_null() {
|
|
self.tabs[i].scene.select_entity(insert_handle, false);
|
|
}
|
|
self.tabs[i].scene.clear_preview_wire();
|
|
self.tabs[i].active_cmd = None;
|
|
self.tabs[i].snap_result = None;
|
|
self.command_line
|
|
.push_output(&format!("Block \"{name}\" created."));
|
|
self.refresh_properties();
|
|
}
|
|
Err(err) => {
|
|
let _ = self.tabs[i].history.undo_stack.pop();
|
|
self.command_line.push_error(&err);
|
|
let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt());
|
|
if let Some(p) = prompt {
|
|
self.command_line.push_info(&p);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
CmdResult::CommitHatch(hatch) => {
|
|
let label = self.history_label_from_active_cmd(i, "HATCH");
|
|
self.push_undo_snapshot(i, label);
|
|
let new_handle = self.tabs[i].scene.add_hatch(hatch);
|
|
if !new_handle.is_null() {
|
|
self.tabs[i].scene.select_entity(new_handle, true);
|
|
}
|
|
self.tabs[i].dirty = true;
|
|
self.tabs[i].scene.clear_preview_wire();
|
|
self.tabs[i].active_cmd = None;
|
|
self.tabs[i].snap_result = None;
|
|
self.restore_pre_cmd_tangent();
|
|
self.refresh_properties();
|
|
}
|
|
CmdResult::BatchCopy(handles, transforms) => {
|
|
let label = self.history_label_from_active_cmd(i, "ARRAY");
|
|
let count = transforms.len();
|
|
// Same gate as COPY (dimension-free), sized by the total number
|
|
// of copies the array will add.
|
|
let delta_safe = self.delta_copy_safe(i, &handles);
|
|
let pending =
|
|
self.begin_undo(i, label.clone(), handles.len() * count, delta_safe);
|
|
for t in &transforms {
|
|
self.tabs[i].scene.copy_entities(&handles, t);
|
|
}
|
|
self.tabs[i].dirty = true;
|
|
self.tabs[i].scene.clear_preview_wire();
|
|
self.tabs[i].active_cmd = None;
|
|
self.tabs[i].snap_result = None;
|
|
self.restore_pre_cmd_tangent();
|
|
let noun = if count == 1 { "copy" } else { "copies" };
|
|
self.command_line
|
|
.push_output(&format!("{label}: {count} {noun} created."));
|
|
self.refresh_properties();
|
|
if let Some(pd) = pending {
|
|
self.commit_undo_delta(i, pd);
|
|
}
|
|
}
|
|
CmdResult::ReplaceMany(replacements, additions) => {
|
|
let label = self.history_label_from_active_cmd(i, "FILLET");
|
|
let was_catchment = self
|
|
.tabs[i]
|
|
.active_cmd
|
|
.as_ref()
|
|
.is_some_and(|c| c.name() == "SS_CATCHMENT");
|
|
self.push_undo_snapshot(i, label);
|
|
for (handle, entities) in replacements {
|
|
self.tabs[i].scene.erase_entities(&[handle]);
|
|
for entity in entities {
|
|
let nh = self.tabs[i].scene.add_entity(entity);
|
|
// Drop a stale *D block on any replaced dimension (#181).
|
|
if matches!(
|
|
self.tabs[i].scene.document.get_entity(nh),
|
|
Some(acadrust::EntityType::Dimension(_))
|
|
) {
|
|
crate::modules::draw::modify::explode::invalidate_dim_block(
|
|
&mut self.tabs[i].scene.document,
|
|
nh,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
for entity in additions {
|
|
self.tabs[i].scene.add_entity(entity);
|
|
}
|
|
self.tabs[i].dirty = true;
|
|
self.tabs[i].scene.clear_preview_wire();
|
|
self.tabs[i].active_cmd = None;
|
|
self.tabs[i].snap_result = None;
|
|
if was_catchment {
|
|
self.command_line
|
|
.push_info("Catchment tagged successfully.");
|
|
}
|
|
self.refresh_properties();
|
|
}
|
|
CmdResult::ReplaceEntity(handle, new_entities) => {
|
|
// Detect SPLINEDIT sentinel: a single XLine with a magic layer name.
|
|
if new_entities.len() == 1 {
|
|
if let acadrust::EntityType::XLine(ref xl) = new_entities[0] {
|
|
let op = xl.common.layer.clone();
|
|
if op.starts_with("__SPLINEDIT_") {
|
|
let label = self.history_label_from_active_cmd(i, "SPLINEDIT");
|
|
self.push_undo_snapshot(i, label);
|
|
crate::modules::draw::modify::splinedit::apply_spline_op(
|
|
&mut self.tabs[i].scene.document,
|
|
handle,
|
|
&op,
|
|
);
|
|
self.tabs[i].dirty = true;
|
|
let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt());
|
|
if let Some(p) = prompt {
|
|
self.command_line.push_info(&p);
|
|
}
|
|
return Task::none();
|
|
}
|
|
}
|
|
}
|
|
// Detect DIMBREAK sentinel.
|
|
if new_entities.len() == 1 {
|
|
if let acadrust::EntityType::XLine(ref xl) = new_entities[0] {
|
|
let layer = xl.common.layer.clone();
|
|
if layer.starts_with("__DIMBREAK__")
|
|
|| layer.starts_with("__DIMBREAK_AUTO__")
|
|
{
|
|
// DIMBREAK needs a break-gap field on the dimension
|
|
// model (not yet present) to store and render the gap.
|
|
// Report honestly rather than claiming success while
|
|
// changing nothing. (#181 / DIM-020)
|
|
self.command_line
|
|
.push_info("DIMBREAK: not yet implemented — nothing changed.");
|
|
self.tabs[i].active_cmd = None;
|
|
self.tabs[i].snap_result = None;
|
|
return Task::none();
|
|
}
|
|
if layer.starts_with("__DIMSPACE__") {
|
|
if let Some(encoded) = layer.strip_prefix("__DIMSPACE__") {
|
|
apply_dimspace(&mut self.tabs[i].scene, encoded);
|
|
}
|
|
self.push_undo_snapshot(i, "DIMSPACE");
|
|
self.command_line.push_output("DIMSPACE Spacing adjusted.");
|
|
self.tabs[i].dirty = true;
|
|
self.tabs[i].active_cmd = None;
|
|
self.tabs[i].snap_result = None;
|
|
return Task::none();
|
|
}
|
|
if layer.starts_with("__DIMJOG__") {
|
|
// DIMJOGLINE needs a jog-point field on the dimension
|
|
// model (not yet present) to store and render the jog.
|
|
// Report honestly rather than faking success. (DIM-019)
|
|
self.command_line
|
|
.push_info("DIMJOGLINE: not yet implemented — nothing changed.");
|
|
self.tabs[i].active_cmd = None;
|
|
self.tabs[i].snap_result = None;
|
|
return Task::none();
|
|
}
|
|
if layer.starts_with("__MLEADERALIGN__") {
|
|
if let Some(encoded) = layer.strip_prefix("__MLEADERALIGN__") {
|
|
apply_mleader_align(&mut self.tabs[i].scene, encoded);
|
|
}
|
|
self.push_undo_snapshot(i, "MLEADERALIGN");
|
|
self.command_line
|
|
.push_output("MLEADERALIGN Leaders aligned.");
|
|
self.tabs[i].dirty = true;
|
|
self.tabs[i].active_cmd = None;
|
|
self.tabs[i].snap_result = None;
|
|
return Task::none();
|
|
}
|
|
if layer.starts_with("__MLEADERCOLLECT__") {
|
|
if let Some(encoded) = layer.strip_prefix("__MLEADERCOLLECT__") {
|
|
apply_mleader_collect(&mut self.tabs[i].scene, encoded);
|
|
}
|
|
self.push_undo_snapshot(i, "MLEADERCOLLECT");
|
|
self.command_line
|
|
.push_output("MLEADERCOLLECT Leaders collected.");
|
|
self.tabs[i].dirty = true;
|
|
self.tabs[i].active_cmd = None;
|
|
self.tabs[i].snap_result = None;
|
|
return Task::none();
|
|
}
|
|
}
|
|
}
|
|
|
|
let label = self.history_label_from_active_cmd(i, "TRIM");
|
|
self.push_undo_snapshot(i, label);
|
|
self.tabs[i].scene.erase_entities(&[handle]);
|
|
let new_handles: Vec<acadrust::Handle> = new_entities
|
|
.into_iter()
|
|
.map(|e| self.tabs[i].scene.add_entity(e))
|
|
.collect();
|
|
// A replaced dimension carries edited geometry/text but still
|
|
// names its old *D block; drop that stale block so the next save
|
|
// re-bakes it — otherwise BricsCAD/ODA draw the pre-edit
|
|
// graphics while OCS shows the edit. (#181)
|
|
for &nh in &new_handles {
|
|
if matches!(
|
|
self.tabs[i].scene.document.get_entity(nh),
|
|
Some(acadrust::EntityType::Dimension(_))
|
|
) {
|
|
crate::modules::draw::modify::explode::invalidate_dim_block(
|
|
&mut self.tabs[i].scene.document,
|
|
nh,
|
|
);
|
|
}
|
|
}
|
|
if let Some(cmd) = &mut self.tabs[i].active_cmd {
|
|
cmd.on_entity_replaced(handle, &new_handles);
|
|
}
|
|
self.tabs[i].dirty = true;
|
|
let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt());
|
|
if let Some(p) = prompt {
|
|
self.command_line.push_info(&p);
|
|
}
|
|
}
|
|
CmdResult::AttreqNeeded { block_name } => {
|
|
// Collect the full AttributeDefinitions owned by this block
|
|
// record so each created attribute keeps its geometry (#255).
|
|
let attdefs: Vec<acadrust::entities::AttributeDefinition> = {
|
|
let doc = &self.tabs[i].scene.document;
|
|
if let Some(br) = doc.block_records.get(&block_name) {
|
|
br.entity_handles
|
|
.iter()
|
|
.filter_map(|&h| {
|
|
if let Some(acadrust::EntityType::AttributeDefinition(ad)) =
|
|
doc.get_entity(h)
|
|
{
|
|
Some(ad.clone())
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect()
|
|
} else {
|
|
vec![]
|
|
}
|
|
};
|
|
|
|
if attdefs.is_empty() {
|
|
// No attribute definitions — commit the INSERT directly.
|
|
let entity = self.tabs[i]
|
|
.active_cmd
|
|
.as_mut()
|
|
.and_then(|c| c.attreq_take_insert());
|
|
if let Some(entity) = entity {
|
|
let label = self.history_label_from_active_cmd(i, "INSERT");
|
|
self.push_undo_snapshot(i, label);
|
|
self.commit_entity(entity);
|
|
self.tabs[i].dirty = true;
|
|
self.tabs[i].scene.clear_preview_wire();
|
|
self.tabs[i].active_cmd = None;
|
|
self.tabs[i].snap_result = None;
|
|
self.restore_pre_cmd_tangent();
|
|
}
|
|
} else {
|
|
// Inject attdefs so the command enters attr-filling mode.
|
|
if let Some(cmd) = &mut self.tabs[i].active_cmd {
|
|
cmd.attreq_set_attdefs(attdefs);
|
|
}
|
|
let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt());
|
|
if let Some(p) = prompt {
|
|
self.command_line.push_info(&p);
|
|
}
|
|
}
|
|
}
|
|
CmdResult::CommitLiveEntity(entity) => {
|
|
let label = self.history_label_from_active_cmd(i, "ENTITY");
|
|
self.push_undo_snapshot(i, label);
|
|
let handle = self.commit_entity_handle(entity);
|
|
self.tabs[i].dirty = true;
|
|
self.tabs[i].scene.clear_preview_wire();
|
|
if let Some(h) = handle {
|
|
if let Some(cmd) = self.tabs[i].active_cmd.as_mut() {
|
|
cmd.set_live_handle(h);
|
|
}
|
|
}
|
|
let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt());
|
|
if let Some(p) = prompt {
|
|
self.command_line.push_info(&p);
|
|
}
|
|
}
|
|
CmdResult::UpdateLiveEntity {
|
|
handle,
|
|
entity,
|
|
finish,
|
|
} => {
|
|
// Replace the live entity's geometry in place, preserving its
|
|
// handle and layer (the fresh entity from the command carries
|
|
// defaults — a NULL handle would desync it from the document
|
|
// map key and drop it from rendering / hit-test). No undo
|
|
// snapshot — the create already pushed one, so the whole object
|
|
// reverts as a unit.
|
|
if let Some(old) = self.tabs[i].scene.document.get_entity_mut(handle) {
|
|
let old_handle = old.as_entity().handle();
|
|
let layer = old.as_entity().layer().to_string();
|
|
let mut new = entity;
|
|
new.as_entity_mut().set_handle(old_handle);
|
|
new.as_entity_mut().set_layer(layer);
|
|
*old = new;
|
|
self.tabs[i].scene.mark_entity_dirty(handle);
|
|
self.tabs[i].scene.bump_geometry_no_blocks();
|
|
self.tabs[i].dirty = true;
|
|
}
|
|
if finish {
|
|
self.tabs[i].scene.clear_preview_wire();
|
|
self.tabs[i].active_cmd = None;
|
|
self.tabs[i].snap_result = None;
|
|
self.restore_pre_cmd_tangent();
|
|
} else {
|
|
let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt());
|
|
if let Some(p) = prompt {
|
|
self.command_line.push_info(&p);
|
|
}
|
|
}
|
|
}
|
|
CmdResult::Cancel => {
|
|
self.tabs[i].scene.clear_preview_wire();
|
|
self.tabs[i].active_cmd = None;
|
|
self.tabs[i].snap_result = None;
|
|
self.restore_pre_cmd_tangent();
|
|
self.command_line.push_info("Command cancelled.");
|
|
}
|
|
CmdResult::Relaunch(cmd, handles) => {
|
|
self.tabs[i].scene.deselect_all();
|
|
for h in &handles {
|
|
self.tabs[i].scene.select_entity(*h, false);
|
|
}
|
|
self.tabs[i].active_cmd = None;
|
|
self.tabs[i].snap_result = None;
|
|
self.tabs[i].scene.clear_preview_wire();
|
|
self.restore_pre_cmd_tangent();
|
|
let _ = self.dispatch_command(&cmd);
|
|
}
|
|
CmdResult::Dispatch(cmd) => {
|
|
// End this interactive front-end, then run the assembled command
|
|
// through the normal dispatcher. Selection is left untouched.
|
|
self.tabs[i].active_cmd = None;
|
|
self.tabs[i].snap_result = None;
|
|
self.tabs[i].scene.clear_preview_wire();
|
|
self.restore_pre_cmd_tangent();
|
|
let _ = self.dispatch_command(&cmd);
|
|
}
|
|
CmdResult::MatchEntityLayer { dest, src } => {
|
|
self.tabs[i].active_cmd = None;
|
|
self.tabs[i].snap_result = None;
|
|
self.tabs[i].scene.clear_preview_wire();
|
|
let src_layer = self.tabs[i]
|
|
.scene
|
|
.document
|
|
.get_entity(src)
|
|
.map(|e| e.common().layer.clone());
|
|
if let Some(layer) = src_layer {
|
|
self.push_undo_snapshot(i, "LAYMATCH");
|
|
for h in &dest {
|
|
if let Some(e) = self.tabs[i].scene.document.get_entity_mut(*h) {
|
|
e.as_entity_mut().set_layer(layer.clone());
|
|
}
|
|
}
|
|
// New layer changes the baked by-layer colour/linetype/
|
|
// lineweight — re-tessellate the moved entities so they
|
|
// repaint immediately (issue #231 class).
|
|
self.invalidate_property_targets(i, &dest);
|
|
self.tabs[i].dirty = true;
|
|
self.command_line
|
|
.push_info(&format!("Layer matched to \"{layer}\"."));
|
|
self.sync_ribbon_layers();
|
|
} else {
|
|
self.command_line.push_error("Source object not found.");
|
|
}
|
|
}
|
|
CmdResult::MatchProperties { dest, src } => {
|
|
// The command stays active after each apply so more targets
|
|
// can keep being picked; Enter / Esc ends it (#362).
|
|
let props = self.tabs[i].scene.document.get_entity(src).map(|e| {
|
|
let c = e.common();
|
|
(
|
|
c.layer.clone(),
|
|
c.color,
|
|
c.linetype.clone(),
|
|
c.linetype_scale,
|
|
c.line_weight,
|
|
)
|
|
});
|
|
// Text-specific properties travel between text-like objects
|
|
// (TEXT ↔ MTEXT, both directions), like AutoCAD's "Text"
|
|
// special property (#361).
|
|
let text_props =
|
|
self.tabs[i]
|
|
.scene
|
|
.document
|
|
.get_entity(src)
|
|
.and_then(|e| match e {
|
|
acadrust::EntityType::Text(t) => Some((t.style.clone(), t.height)),
|
|
acadrust::EntityType::MText(m) => Some((m.style.clone(), m.height)),
|
|
_ => None,
|
|
});
|
|
|
|
if let Some((layer, color, linetype, lt_scale, lw)) = props {
|
|
self.push_undo_snapshot(i, "MATCHPROP");
|
|
for h in &dest {
|
|
if let Some(e) = self.tabs[i].scene.document.get_entity_mut(*h) {
|
|
e.as_entity_mut().set_layer(layer.clone());
|
|
crate::scene::view::dispatch::apply_color(e, color);
|
|
crate::scene::view::dispatch::apply_line_weight(e, lw);
|
|
e.common_mut().linetype = linetype.clone();
|
|
e.common_mut().linetype_scale = lt_scale;
|
|
if let Some((style, height)) = &text_props {
|
|
match e {
|
|
acadrust::EntityType::Text(t) => {
|
|
t.style = style.clone();
|
|
t.height = *height;
|
|
}
|
|
acadrust::EntityType::MText(m) => {
|
|
m.style = style.clone();
|
|
m.height = *height;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
self.tabs[i].dirty = true;
|
|
// Color / linetype / lineweight are baked into the cached
|
|
// wires at tessellation time; bump the geometry epoch so the
|
|
// matched objects repaint instead of holding their old look.
|
|
self.tabs[i].scene.bump_geometry();
|
|
self.refresh_properties();
|
|
self.command_line
|
|
.push_info(&format!("Properties matched to {} object(s).", dest.len()));
|
|
// Clear the consumed target selection and keep prompting.
|
|
self.tabs[i].scene.deselect_all();
|
|
if let Some(cmd) = &self.tabs[i].active_cmd {
|
|
self.command_line.push_info(&cmd.prompt());
|
|
}
|
|
} else {
|
|
self.command_line.push_error("Source object not found.");
|
|
self.tabs[i].active_cmd = None;
|
|
self.tabs[i].snap_result = None;
|
|
self.tabs[i].scene.clear_preview_wire();
|
|
}
|
|
}
|
|
CmdResult::PasteClipboard { base_pt } => {
|
|
self.tabs[i].active_cmd = None;
|
|
self.tabs[i].snap_result = None;
|
|
self.tabs[i].scene.clear_preview_wire();
|
|
if self.clipboard.is_empty() {
|
|
self.command_line.push_error("Clipboard is empty.");
|
|
} else {
|
|
let delta = base_pt - self.clipboard_base;
|
|
let translate = crate::command::EntityTransform::Translate(delta);
|
|
self.push_undo_snapshot(i, "PASTECLIP");
|
|
let count = self.clipboard.len();
|
|
let by_index = self.finalize_paste(i, Some(translate));
|
|
self.tabs[i].scene.deselect_all();
|
|
for h in by_index.iter().copied().filter(|h| !h.is_null()) {
|
|
self.tabs[i].scene.select_entity(h, false);
|
|
}
|
|
self.tabs[i].dirty = true;
|
|
// Surface any layers the paste brought in (cross-drawing)
|
|
// in the layer manager and the layer dropdown.
|
|
self.refresh_layer_panel();
|
|
self.refresh_properties();
|
|
self.command_line
|
|
.push_info(&format!("{count} object(s) pasted."));
|
|
}
|
|
}
|
|
CmdResult::CreateGroup { handles, name } => {
|
|
self.tabs[i].active_cmd = None;
|
|
self.tabs[i].snap_result = None;
|
|
self.tabs[i].scene.clear_preview_wire();
|
|
self.push_undo_snapshot(i, "GROUP");
|
|
self.tabs[i].scene.create_group(name.clone(), handles);
|
|
self.tabs[i].dirty = true;
|
|
self.command_line
|
|
.push_info(&format!("Group \"{}\" created.", name));
|
|
}
|
|
CmdResult::DeleteGroups { handles } => {
|
|
self.tabs[i].active_cmd = None;
|
|
self.tabs[i].snap_result = None;
|
|
self.tabs[i].scene.clear_preview_wire();
|
|
self.push_undo_snapshot(i, "UNGROUP");
|
|
let count = self.tabs[i].scene.delete_groups_containing(&handles);
|
|
self.tabs[i].dirty = true;
|
|
if count > 0 {
|
|
self.command_line
|
|
.push_info(&format!("{} group(s) dissolved.", count));
|
|
} else {
|
|
self.command_line
|
|
.push_info("No groups found for selected objects.");
|
|
}
|
|
}
|
|
CmdResult::VpLayerUpdate {
|
|
vp_handle,
|
|
freeze,
|
|
thaw,
|
|
} => {
|
|
// Resolve layer names → handles, then update frozen_layers on the viewport(s).
|
|
// vp_handle == Handle::NULL means "apply to all viewports in current layout".
|
|
let freeze_handles: Vec<Handle> = freeze
|
|
.iter()
|
|
.filter_map(|name| {
|
|
self.tabs[i]
|
|
.scene
|
|
.document
|
|
.layers
|
|
.iter()
|
|
.find(|l| l.name.eq_ignore_ascii_case(name))
|
|
.map(|l| l.handle)
|
|
})
|
|
.collect();
|
|
let thaw_handles: Vec<Handle> = thaw
|
|
.iter()
|
|
.filter_map(|name| {
|
|
self.tabs[i]
|
|
.scene
|
|
.document
|
|
.layers
|
|
.iter()
|
|
.find(|l| l.name.eq_ignore_ascii_case(name))
|
|
.map(|l| l.handle)
|
|
})
|
|
.collect();
|
|
|
|
let mut frozen_count = 0usize;
|
|
let mut thawed_count = 0usize;
|
|
|
|
// Collect target viewport handles
|
|
let target_handles: Vec<Handle> = if vp_handle == acadrust::Handle::NULL {
|
|
// All viewports in current layout block
|
|
let block_handle = self.tabs[i].scene.current_layout_block_handle_pub();
|
|
self.tabs[i]
|
|
.scene
|
|
.document
|
|
.entities()
|
|
.filter(|e| {
|
|
e.common().owner_handle == block_handle
|
|
&& matches!(e, acadrust::EntityType::Viewport(_))
|
|
})
|
|
.map(|e| e.common().handle)
|
|
.collect()
|
|
} else {
|
|
vec![vp_handle]
|
|
};
|
|
|
|
for &target_handle in &target_handles {
|
|
if let Some(acadrust::EntityType::Viewport(vp)) =
|
|
self.tabs[i].scene.document.get_entity_mut(target_handle)
|
|
{
|
|
for h in &freeze_handles {
|
|
if !vp.frozen_layers.contains(h) {
|
|
vp.frozen_layers.push(*h);
|
|
frozen_count += 1;
|
|
}
|
|
}
|
|
for h in &thaw_handles {
|
|
let before = vp.frozen_layers.len();
|
|
vp.frozen_layers.retain(|fh| fh != h);
|
|
if vp.frozen_layers.len() < before {
|
|
thawed_count += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if frozen_count > 0 || thawed_count > 0 {
|
|
self.push_undo_snapshot(i, "VPLAYER");
|
|
self.tabs[i].dirty = true;
|
|
if frozen_count > 0 {
|
|
self.command_line.push_info(&format!(
|
|
"VPLAYER: {frozen_count} layer(s) frozen in viewport."
|
|
));
|
|
}
|
|
if thawed_count > 0 {
|
|
self.command_line.push_info(&format!(
|
|
"VPLAYER: {thawed_count} layer(s) thawed in viewport."
|
|
));
|
|
}
|
|
// Sync layer panel so VP freeze columns update immediately.
|
|
let doc_layers = self.tabs[i].scene.document.layers.clone();
|
|
let vp_info = self.tabs[i].scene.viewport_list();
|
|
self.tabs[i]
|
|
.layers
|
|
.sync_with_viewports(&doc_layers, vp_info);
|
|
}
|
|
|
|
// Show updated prompt (command stays active for more operations).
|
|
let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt());
|
|
if let Some(p) = prompt {
|
|
self.command_line.push_info(&p);
|
|
}
|
|
}
|
|
|
|
CmdResult::ZoomToWindow { p1, p2 } => {
|
|
self.tabs[i].active_cmd = None;
|
|
self.tabs[i].snap_result = None;
|
|
self.tabs[i].scene.clear_preview_wire();
|
|
self.tabs[i].scene.zoom_to_window(p1.as_vec3(), p2.as_vec3());
|
|
self.command_line.push_output("Zoom Window");
|
|
}
|
|
CmdResult::Measurement(msg) => {
|
|
self.tabs[i].active_cmd = None;
|
|
self.tabs[i].snap_result = None;
|
|
self.tabs[i].scene.clear_preview_wire();
|
|
self.restore_pre_cmd_tangent();
|
|
self.command_line.push_output(&msg);
|
|
}
|
|
CmdResult::AlignSelected {
|
|
handles,
|
|
src1,
|
|
dst1,
|
|
angle_rad,
|
|
scale,
|
|
} => {
|
|
if handles.is_empty() {
|
|
self.tabs[i].active_cmd = None;
|
|
self.tabs[i].snap_result = None;
|
|
self.tabs[i].scene.clear_preview_wire();
|
|
self.restore_pre_cmd_tangent();
|
|
} else {
|
|
let label = self.history_label_from_active_cmd(i, "ALIGN");
|
|
// A chain of transform_entities on the same handles — the
|
|
// recording keeps each entity's first (pre-align) image, so
|
|
// it's delta-safe like a single transform.
|
|
let pending = self.begin_undo(i, label, handles.len(), true);
|
|
// Step 1: translate so src1 is at origin
|
|
self.tabs[i].scene.transform_entities(
|
|
&handles,
|
|
&crate::command::EntityTransform::Translate(-src1),
|
|
);
|
|
// Step 2: uniform scale (only when != 1)
|
|
if (scale - 1.0).abs() > 1e-4 {
|
|
self.tabs[i].scene.transform_entities(
|
|
&handles,
|
|
&crate::command::EntityTransform::Scale {
|
|
center: glam::DVec3::ZERO,
|
|
factor: scale,
|
|
},
|
|
);
|
|
}
|
|
// Step 3: rotate in the XY plane by angle_rad
|
|
if angle_rad.abs() > 1e-4 {
|
|
self.tabs[i].scene.transform_entities(
|
|
&handles,
|
|
&crate::command::EntityTransform::Rotate {
|
|
center: glam::DVec3::ZERO,
|
|
angle_rad,
|
|
},
|
|
);
|
|
}
|
|
// Step 4: translate to dst1
|
|
self.tabs[i].scene.transform_entities(
|
|
&handles,
|
|
&crate::command::EntityTransform::Translate(dst1),
|
|
);
|
|
self.tabs[i].dirty = true;
|
|
self.tabs[i].scene.deselect_all();
|
|
for h in &handles {
|
|
self.tabs[i].scene.select_entity(*h, false);
|
|
}
|
|
self.tabs[i].scene.clear_preview_wire();
|
|
self.tabs[i].active_cmd = None;
|
|
self.tabs[i].snap_result = None;
|
|
self.restore_pre_cmd_tangent();
|
|
self.command_line.push_output("ALIGN: applied.");
|
|
self.refresh_properties();
|
|
if let Some(pd) = pending {
|
|
self.commit_undo_delta(i, pd);
|
|
}
|
|
}
|
|
}
|
|
CmdResult::LengthenEntity {
|
|
handle,
|
|
pick_pt,
|
|
mode,
|
|
} => {
|
|
use crate::modules::draw::modify::lengthen::lengthen_entity;
|
|
let result = self.tabs[i]
|
|
.scene
|
|
.document
|
|
.get_entity(handle)
|
|
.and_then(|e| lengthen_entity(e, pick_pt.as_vec3(), &mode));
|
|
match result {
|
|
Some(new_entity) => {
|
|
let label = self.history_label_from_active_cmd(i, "LENGTHEN");
|
|
self.push_undo_snapshot(i, label);
|
|
self.tabs[i].scene.erase_entities(&[handle]);
|
|
self.tabs[i].scene.add_entity(new_entity);
|
|
self.tabs[i].dirty = true;
|
|
self.command_line.push_output("LENGTHEN: applied.");
|
|
self.refresh_properties();
|
|
}
|
|
None => {
|
|
self.command_line
|
|
.push_error("LENGTHEN: entity type not supported.");
|
|
}
|
|
}
|
|
self.tabs[i].active_cmd = None;
|
|
self.tabs[i].snap_result = None;
|
|
self.tabs[i].scene.clear_preview_wire();
|
|
self.restore_pre_cmd_tangent();
|
|
}
|
|
CmdResult::DivideEntity { handle, n } => {
|
|
use crate::modules::draw::inquiry::divide::divide_entity;
|
|
let pts = self.tabs[i]
|
|
.scene
|
|
.document
|
|
.get_entity(handle)
|
|
.map(|e| divide_entity(e, n))
|
|
.unwrap_or_default();
|
|
let count = pts.len();
|
|
if count > 0 {
|
|
self.push_undo_snapshot(i, "DIVIDE");
|
|
for p in pts {
|
|
self.tabs[i].scene.add_entity(p);
|
|
}
|
|
self.tabs[i].dirty = true;
|
|
self.command_line
|
|
.push_output(&format!("DIVIDE: {count} point(s) placed."));
|
|
} else {
|
|
self.command_line
|
|
.push_error("DIVIDE: entity type not supported or N < 2.");
|
|
}
|
|
self.tabs[i].active_cmd = None;
|
|
self.tabs[i].snap_result = None;
|
|
self.tabs[i].scene.clear_preview_wire();
|
|
self.restore_pre_cmd_tangent();
|
|
}
|
|
CmdResult::MeasureEntity {
|
|
handle,
|
|
segment_length,
|
|
} => {
|
|
use crate::modules::draw::inquiry::divide::measure_entity;
|
|
let pts = self.tabs[i]
|
|
.scene
|
|
.document
|
|
.get_entity(handle)
|
|
.map(|e| measure_entity(e, segment_length))
|
|
.unwrap_or_default();
|
|
let count = pts.len();
|
|
if count > 0 {
|
|
self.push_undo_snapshot(i, "MEASURE");
|
|
for p in pts {
|
|
self.tabs[i].scene.add_entity(p);
|
|
}
|
|
self.tabs[i].dirty = true;
|
|
self.command_line
|
|
.push_output(&format!("MEASURE: {count} point(s) placed."));
|
|
} else {
|
|
self.command_line
|
|
.push_error("MEASURE: entity type not supported or distance too large.");
|
|
}
|
|
self.tabs[i].active_cmd = None;
|
|
self.tabs[i].snap_result = None;
|
|
self.tabs[i].scene.clear_preview_wire();
|
|
self.restore_pre_cmd_tangent();
|
|
}
|
|
CmdResult::PeditOp { handle, op } => {
|
|
use crate::modules::draw::modify::pedit::apply_pedit;
|
|
let changed = self.tabs[i]
|
|
.scene
|
|
.document
|
|
.get_entity_mut(handle)
|
|
.map(|e| apply_pedit(e, &op))
|
|
.unwrap_or(false);
|
|
if changed {
|
|
self.push_undo_snapshot(i, "PEDIT");
|
|
self.tabs[i].dirty = true;
|
|
self.command_line.push_output("PEDIT: applied.");
|
|
self.refresh_properties();
|
|
} else {
|
|
self.command_line
|
|
.push_error("PEDIT: operation not applicable to this entity.");
|
|
}
|
|
// Keep command active — user may apply more ops
|
|
self.command_line
|
|
.push_info("PEDIT Enter option [C=Close O=Open W=Width X=Exit]:");
|
|
}
|
|
CmdResult::JoinEntities(handles) => {
|
|
use crate::modules::draw::modify::join::join_entities;
|
|
let pairs: Vec<_> = handles
|
|
.iter()
|
|
.filter_map(|&h| self.tabs[i].scene.document.get_entity(h).map(|e| (h, e)))
|
|
.collect();
|
|
match join_entities(&pairs) {
|
|
Some((to_remove, merged)) => {
|
|
let label = self.history_label_from_active_cmd(i, "JOIN");
|
|
self.push_undo_snapshot(i, label);
|
|
self.tabs[i].scene.erase_entities(&to_remove);
|
|
let count_in = to_remove.len();
|
|
let count_out = merged.len();
|
|
for e in merged {
|
|
self.tabs[i].scene.add_entity(e);
|
|
}
|
|
self.tabs[i].dirty = true;
|
|
self.tabs[i].scene.clear_preview_wire();
|
|
self.tabs[i].active_cmd = None;
|
|
self.tabs[i].snap_result = None;
|
|
self.restore_pre_cmd_tangent();
|
|
self.command_line.push_output(&format!(
|
|
"JOIN: {count_in} object(s) joined into {count_out}."
|
|
));
|
|
self.refresh_properties();
|
|
}
|
|
None => {
|
|
self.tabs[i].active_cmd = None;
|
|
self.tabs[i].snap_result = None;
|
|
self.tabs[i].scene.clear_preview_wire();
|
|
self.restore_pre_cmd_tangent();
|
|
self.command_line.push_error(
|
|
"JOIN: objects don't form a single connected chain, or contain an unsupported type / tilted arc.",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
CmdResult::BreakEntity { handle, p1, p2 } => {
|
|
use crate::modules::draw::modify::break_cmd::break_entity;
|
|
let replacement = self.tabs[i]
|
|
.scene
|
|
.document
|
|
.get_entity(handle)
|
|
.and_then(|e| break_entity(e, p1, p2));
|
|
match replacement {
|
|
Some(frags) => {
|
|
let label = self.history_label_from_active_cmd(i, "BREAK");
|
|
self.push_undo_snapshot(i, label);
|
|
self.tabs[i].scene.erase_entities(&[handle]);
|
|
let count = frags.len();
|
|
for e in frags {
|
|
self.tabs[i].scene.add_entity(e);
|
|
}
|
|
self.tabs[i].dirty = true;
|
|
self.tabs[i].scene.clear_preview_wire();
|
|
self.tabs[i].active_cmd = None;
|
|
self.tabs[i].snap_result = None;
|
|
self.restore_pre_cmd_tangent();
|
|
self.command_line
|
|
.push_output(&format!("BREAK: {} fragment(s).", count));
|
|
self.refresh_properties();
|
|
}
|
|
None => {
|
|
self.tabs[i].active_cmd = None;
|
|
self.tabs[i].snap_result = None;
|
|
self.tabs[i].scene.clear_preview_wire();
|
|
self.restore_pre_cmd_tangent();
|
|
self.command_line
|
|
.push_error("BREAK: entity type not supported.");
|
|
}
|
|
}
|
|
}
|
|
CmdResult::SetPlotWindow { p1, p2 } => {
|
|
use acadrust::objects::{ObjectType, PlotSettings};
|
|
let layout_name = self.tabs[i].scene.current_layout.clone();
|
|
if layout_name == "Model" {
|
|
// Model space: remember the window (world X/Y) for the plot dialog.
|
|
let x0 = p1.x.min(p2.x);
|
|
let y0 = p1.y.min(p2.y);
|
|
let x1 = p1.x.max(p2.x);
|
|
let y1 = p1.y.max(p2.y);
|
|
self.plot_window = Some((x0, y0, x1, y1));
|
|
self.command_line.push_output(&format!(
|
|
"Plot window: {x0:.2},{y0:.2} to {x1:.2},{y1:.2}"
|
|
));
|
|
// Pick window closed the plot dialog so the viewport could
|
|
// receive the two clicks — bring the dialog back with the
|
|
// window now active.
|
|
self.plot_dialog.area = "Window".to_string();
|
|
self.active_modal = Some(super::ModalKind::Plot);
|
|
} else {
|
|
let block_handle = self.tabs[i].scene.current_layout_block_handle_pub();
|
|
let doc = &mut self.tabs[i].scene.document;
|
|
let ps_handle = doc.objects.iter().find_map(|(h, obj)| {
|
|
if let ObjectType::PlotSettings(ps) = obj {
|
|
if ps.page_name == layout_name {
|
|
Some(*h)
|
|
} else {
|
|
None
|
|
}
|
|
} else {
|
|
None
|
|
}
|
|
});
|
|
let ps_entry = match ps_handle {
|
|
Some(h) => doc.objects.get_mut(&h),
|
|
None => {
|
|
let nh = acadrust::Handle::new(doc.next_handle());
|
|
let ps = PlotSettings::new(layout_name.clone());
|
|
doc.objects.insert(nh, ObjectType::PlotSettings(ps));
|
|
doc.objects.get_mut(&nh)
|
|
}
|
|
};
|
|
let _ = block_handle;
|
|
if let Some(ObjectType::PlotSettings(ps)) = ps_entry {
|
|
// Convert world-space points to DXF coordinates (X, Z plane → DXF X, Y).
|
|
let x1 = p1.x.min(p2.x) as f64;
|
|
let y1 = p1.z.min(p2.z) as f64;
|
|
let x2 = p1.x.max(p2.x) as f64;
|
|
let y2 = p1.z.max(p2.z) as f64;
|
|
ps.set_plot_window(x1, y1, x2, y2);
|
|
self.push_undo_snapshot(i, "PLOTWINDOW");
|
|
self.tabs[i].dirty = true;
|
|
self.command_line.push_output(&format!(
|
|
"PLOTWINDOW: ({x1:.3},{y1:.3}) → ({x2:.3},{y2:.3})"
|
|
));
|
|
}
|
|
}
|
|
self.tabs[i].active_cmd = None;
|
|
self.tabs[i].snap_result = None;
|
|
self.tabs[i].scene.clear_preview_wire();
|
|
self.restore_pre_cmd_tangent();
|
|
}
|
|
CmdResult::QuickPrint(handles) => {
|
|
self.tabs[i].active_cmd = None;
|
|
self.tabs[i].snap_result = None;
|
|
self.tabs[i].scene.clear_preview_wire();
|
|
self.restore_pre_cmd_tangent();
|
|
return self.on_quick_print_handles(handles);
|
|
}
|
|
CmdResult::StretchWindow { win_min, win_max } => {
|
|
// Implicit STRETCH selection (#338): the crossing window drawn
|
|
// with no prior selection picks the objects itself. Entities
|
|
// whose world AABB touches the window are handed back to the
|
|
// command at the base-point step — over-selection is harmless,
|
|
// since only points INSIDE the window move anyway.
|
|
let mut handles: Vec<Handle> = Vec::new();
|
|
{
|
|
let scene = &self.tabs[i].scene;
|
|
let mut seen: rustc_hash::FxHashSet<Handle> = rustc_hash::FxHashSet::default();
|
|
for w in scene.entity_wires().iter() {
|
|
let Some(h) = crate::scene::Scene::handle_from_wire_name(&w.name) else {
|
|
continue;
|
|
};
|
|
if !seen.insert(h) || scene.is_layer_locked(h) {
|
|
continue;
|
|
}
|
|
let [ax, ay, bx, by] = w.aabb;
|
|
if ax as f64 <= win_max.x
|
|
&& bx as f64 >= win_min.x
|
|
&& ay as f64 <= win_max.y
|
|
&& by as f64 >= win_min.y
|
|
{
|
|
handles.push(h);
|
|
}
|
|
}
|
|
}
|
|
if handles.is_empty() {
|
|
self.command_line
|
|
.push_output("STRETCH: nothing crosses the window.");
|
|
self.tabs[i].active_cmd = None;
|
|
self.tabs[i].snap_result = None;
|
|
self.tabs[i].scene.clear_preview_wire();
|
|
self.restore_pre_cmd_tangent();
|
|
} else {
|
|
use crate::command::CadCommand;
|
|
use crate::modules::draw::modify::stretch::StretchCommand;
|
|
let wires = self.tabs[i].scene.wire_models_for(&handles);
|
|
let cmd = StretchCommand::with_window(handles, wires, win_min, win_max);
|
|
self.command_line.push_info(&CadCommand::prompt(&cmd));
|
|
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
|
}
|
|
}
|
|
CmdResult::StretchEntities {
|
|
handles,
|
|
win_min,
|
|
win_max,
|
|
delta,
|
|
} => {
|
|
self.push_undo_snapshot(i, "STRETCH");
|
|
let mut count = 0usize;
|
|
|
|
// Helper: is DXF point (x, y) inside the world-space window?
|
|
// Drawing plane is world XY (= DXF XY).
|
|
let in_win = |x: f64, y: f64| -> bool {
|
|
x >= win_min.x && x <= win_max.x && y >= win_min.y && y <= win_max.y
|
|
};
|
|
|
|
let dx = delta.x as f64;
|
|
let dy = delta.y as f64; // drawing plane is world XY
|
|
let dz = delta.z as f64;
|
|
|
|
// Dimensions whose points moved — their baked *D block is
|
|
// stale afterwards and must be dropped (see #398 / #372).
|
|
let mut stretched_dims: Vec<acadrust::Handle> = Vec::new();
|
|
for handle in &handles {
|
|
let Some(entity) = self.tabs[i].scene.document.get_entity_mut(*handle) else {
|
|
continue;
|
|
};
|
|
let mut stretched = false;
|
|
match entity {
|
|
acadrust::EntityType::Line(l) => {
|
|
let s_in = in_win(l.start.x, l.start.y);
|
|
let e_in = in_win(l.end.x, l.end.y);
|
|
if s_in {
|
|
l.start.x += dx;
|
|
l.start.y += dy;
|
|
l.start.z += dz;
|
|
stretched = true;
|
|
}
|
|
if e_in {
|
|
l.end.x += dx;
|
|
l.end.y += dy;
|
|
l.end.z += dz;
|
|
stretched = true;
|
|
}
|
|
}
|
|
acadrust::EntityType::LwPolyline(p) => {
|
|
for v in &mut p.vertices {
|
|
if in_win(v.location.x, v.location.y) {
|
|
v.location.x += dx;
|
|
v.location.y += dy;
|
|
stretched = true;
|
|
}
|
|
}
|
|
}
|
|
acadrust::EntityType::Polyline2D(p) => {
|
|
for v in &mut p.vertices {
|
|
if in_win(v.location.x, v.location.y) {
|
|
v.location.x += dx;
|
|
v.location.y += dy;
|
|
stretched = true;
|
|
}
|
|
}
|
|
}
|
|
acadrust::EntityType::Polyline(p) => {
|
|
for v in &mut p.vertices {
|
|
if in_win(v.location.x, v.location.z) {
|
|
v.location.x += dx;
|
|
v.location.z += dy;
|
|
stretched = true;
|
|
}
|
|
}
|
|
}
|
|
acadrust::EntityType::Arc(a) => {
|
|
if in_win(a.center.x, a.center.y) {
|
|
a.center.x += dx;
|
|
a.center.y += dy;
|
|
a.center.z += dz;
|
|
stretched = true;
|
|
}
|
|
}
|
|
acadrust::EntityType::Circle(c) => {
|
|
if in_win(c.center.x, c.center.y) {
|
|
c.center.x += dx;
|
|
c.center.y += dy;
|
|
c.center.z += dz;
|
|
stretched = true;
|
|
}
|
|
}
|
|
acadrust::EntityType::Ellipse(e) => {
|
|
if in_win(e.center.x, e.center.y) {
|
|
e.center.x += dx;
|
|
e.center.y += dy;
|
|
e.center.z += dz;
|
|
stretched = true;
|
|
}
|
|
}
|
|
acadrust::EntityType::Insert(ins) => {
|
|
if in_win(ins.insert_point.x, ins.insert_point.y) {
|
|
ins.insert_point.x += dx;
|
|
ins.insert_point.y += dy;
|
|
ins.insert_point.z += dz;
|
|
stretched = true;
|
|
}
|
|
}
|
|
acadrust::EntityType::Text(t) => {
|
|
if in_win(t.insertion_point.x, t.insertion_point.y) {
|
|
t.insertion_point.x += dx;
|
|
t.insertion_point.y += dy;
|
|
t.insertion_point.z += dz;
|
|
stretched = true;
|
|
}
|
|
}
|
|
acadrust::EntityType::MText(t) => {
|
|
if in_win(t.insertion_point.x, t.insertion_point.y) {
|
|
t.insertion_point.x += dx;
|
|
t.insertion_point.y += dy;
|
|
t.insertion_point.z += dz;
|
|
stretched = true;
|
|
}
|
|
}
|
|
acadrust::EntityType::Dimension(dim) => {
|
|
use acadrust::entities::Dimension;
|
|
// Move every definition point that falls inside the
|
|
// window — the same points the grips expose — then
|
|
// refresh the stored measurement so the value tracks
|
|
// the stretched geometry.
|
|
let mut mv = |p: &mut acadrust::types::Vector3| {
|
|
if in_win(p.x, p.y) {
|
|
p.x += dx;
|
|
p.y += dy;
|
|
p.z += dz;
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
};
|
|
match dim {
|
|
Dimension::Linear(d) => {
|
|
stretched |= mv(&mut d.first_point);
|
|
stretched |= mv(&mut d.second_point);
|
|
stretched |= mv(&mut d.definition_point);
|
|
}
|
|
Dimension::Aligned(d) => {
|
|
stretched |= mv(&mut d.first_point);
|
|
stretched |= mv(&mut d.second_point);
|
|
stretched |= mv(&mut d.definition_point);
|
|
}
|
|
Dimension::Radius(d) => {
|
|
stretched |= mv(&mut d.angle_vertex);
|
|
stretched |= mv(&mut d.definition_point);
|
|
}
|
|
Dimension::Diameter(d) => {
|
|
stretched |= mv(&mut d.angle_vertex);
|
|
stretched |= mv(&mut d.definition_point);
|
|
}
|
|
Dimension::Angular2Ln(d) => {
|
|
stretched |= mv(&mut d.angle_vertex);
|
|
stretched |= mv(&mut d.first_point);
|
|
stretched |= mv(&mut d.second_point);
|
|
stretched |= mv(&mut d.definition_point);
|
|
}
|
|
Dimension::Angular3Pt(d) => {
|
|
stretched |= mv(&mut d.angle_vertex);
|
|
stretched |= mv(&mut d.first_point);
|
|
stretched |= mv(&mut d.second_point);
|
|
stretched |= mv(&mut d.definition_point);
|
|
}
|
|
Dimension::Ordinate(d) => {
|
|
stretched |= mv(&mut d.definition_point);
|
|
stretched |= mv(&mut d.feature_location);
|
|
stretched |= mv(&mut d.leader_endpoint);
|
|
}
|
|
}
|
|
// Pinned text follows too; the zero sentinel means
|
|
// "auto placement" and must not be captured by a
|
|
// window that happens to cover the origin.
|
|
let t = dim.base().text_middle_point;
|
|
if t.x * t.x + t.y * t.y + t.z * t.z > 1e-16 {
|
|
let mut t = t;
|
|
if mv(&mut t) {
|
|
dim.base_mut().text_middle_point = t;
|
|
stretched = true;
|
|
}
|
|
}
|
|
if stretched {
|
|
dim.base_mut().actual_measurement = dim.measurement();
|
|
stretched_dims.push(*handle);
|
|
}
|
|
}
|
|
_ => {
|
|
// Generic: move entire entity (treat as block-level)
|
|
stretched = false; // skip generic types
|
|
}
|
|
}
|
|
if stretched {
|
|
self.tabs[i].scene.mark_entity_dirty(*handle);
|
|
count += 1;
|
|
}
|
|
}
|
|
// A stretched dimension renders through its baked *D block when
|
|
// one exists (file roundtrip) — drop it so tessellation falls
|
|
// back to the live points and the next save re-bakes. (#372)
|
|
for h in stretched_dims {
|
|
crate::modules::draw::modify::explode::invalidate_dim_block(
|
|
&mut self.tabs[i].scene.document,
|
|
h,
|
|
);
|
|
}
|
|
|
|
// Geometry was edited in place via get_entity_mut, which the
|
|
// scene's tessellation cache doesn't observe. Re-tessellate the
|
|
// moved entities so the viewport reflects the stretch right away
|
|
// instead of only on the next unrelated redraw. See #95.
|
|
if count > 0 {
|
|
self.tabs[i].scene.bump_geometry_no_blocks();
|
|
}
|
|
self.tabs[i].dirty = true;
|
|
self.tabs[i].active_cmd = None;
|
|
self.tabs[i].snap_result = None;
|
|
self.tabs[i].scene.clear_preview_wire();
|
|
self.restore_pre_cmd_tangent();
|
|
self.command_line
|
|
.push_output(&format!("STRETCH: {count} entity(ies) stretched."));
|
|
self.refresh_properties();
|
|
}
|
|
// ── Solid3D creation (BOX / SPHERE / CYLINDER) ────────────────
|
|
CmdResult::CommitSolid3D { mesh_fn } => {
|
|
use crate::modules::insert::solid3d_cmds::empty_solid3d;
|
|
self.push_undo_snapshot(i, "SOLID3D");
|
|
let entity = empty_solid3d();
|
|
let handle = self.tabs[i].scene.add_entity(entity);
|
|
if !handle.is_null() {
|
|
let name = format!("{}", handle.value());
|
|
let color = [0.6f32, 0.6, 0.8, 1.0]; // default colour; command embedded it
|
|
let _ = color; // color is captured inside mesh_fn
|
|
if let Some(mesh) = mesh_fn(name) {
|
|
self.tabs[i].scene.meshes.insert(handle, crate::scene::MeshLodSet::from_single(mesh));
|
|
}
|
|
self.tabs[i].dirty = true;
|
|
self.command_line.push_output("Solid created.");
|
|
}
|
|
self.tabs[i].active_cmd = None;
|
|
self.tabs[i].snap_result = None;
|
|
self.tabs[i].scene.clear_preview_wire();
|
|
self.restore_pre_cmd_tangent();
|
|
}
|
|
|
|
// ── EXTRUDE ────────────────────────────────────────────────────
|
|
CmdResult::ExtrudeEntity {
|
|
handle,
|
|
height,
|
|
color,
|
|
} => {
|
|
use crate::entities::traits::EntityTypeOps;
|
|
use crate::modules::insert::solid3d_cmds::empty_solid3d;
|
|
use crate::scene::convert::acad_to_truck::TruckObject;
|
|
use crate::scene::convert::truck_tess;
|
|
use truck_modeling::builder;
|
|
use truck_modeling::Vector3 as TruckVec3;
|
|
|
|
let entity_opt = self.tabs[i].scene.document.get_entity(handle).cloned();
|
|
if let Some(entity) = entity_opt {
|
|
let truck_entity = entity.to_truck_entity(&self.tabs[i].scene.document);
|
|
let result = truck_entity.and_then(|te| {
|
|
match te.object {
|
|
TruckObject::Contour(wire) => {
|
|
// Attach a planar face to the wire profile, then sweep.
|
|
let face = builder::try_attach_plane(&[wire]).ok()?;
|
|
// tsweep(Face) → Solid
|
|
let solid =
|
|
builder::tsweep(&face, TruckVec3::new(0.0, 0.0, height as f64));
|
|
match truck_tess::tessellate_solid(&solid) {
|
|
truck_tess::TruckTessResult::Mesh {
|
|
verts,
|
|
verts_low,
|
|
normals,
|
|
indices,
|
|
} => Some((crate::scene::model::mesh_model::MeshModel {
|
|
name: String::new(),
|
|
verts,
|
|
verts_low,
|
|
normals,
|
|
indices,
|
|
color,
|
|
selected: false,
|
|
}, solid)),
|
|
_ => None,
|
|
}
|
|
}
|
|
_ => None,
|
|
}
|
|
});
|
|
if let Some((mut mesh, solid)) = result {
|
|
self.push_undo_snapshot(i, "EXTRUDE");
|
|
let new_entity = empty_solid3d();
|
|
let new_handle = self.tabs[i].scene.add_entity(new_entity);
|
|
mesh.name = format!("{}", new_handle.value());
|
|
self.tabs[i].scene.meshes.insert(new_handle, crate::scene::MeshLodSet::from_single(mesh));
|
|
// Keep the truck B-rep so the save path can export exact
|
|
// ACIS geometry (else the solid is dropped by other CAD apps).
|
|
self.tabs[i].scene.solid_models.insert(new_handle, solid);
|
|
self.tabs[i].dirty = true;
|
|
self.command_line.push_output("EXTRUDE: solid created.");
|
|
} else {
|
|
self.command_line.push_error("EXTRUDE: could not build profile. Select a closed 2D entity (Circle, LwPolyline, etc.).");
|
|
}
|
|
} else {
|
|
self.command_line.push_error("EXTRUDE: entity not found.");
|
|
}
|
|
self.tabs[i].active_cmd = None;
|
|
self.tabs[i].snap_result = None;
|
|
self.tabs[i].scene.clear_preview_wire();
|
|
self.restore_pre_cmd_tangent();
|
|
}
|
|
|
|
// ── REVOLVE ────────────────────────────────────────────────────
|
|
CmdResult::RevolveEntity {
|
|
handle,
|
|
axis_start,
|
|
axis_end,
|
|
angle_deg,
|
|
color,
|
|
} => {
|
|
use crate::entities::traits::EntityTypeOps;
|
|
use crate::modules::insert::solid3d_cmds::empty_solid3d;
|
|
use crate::scene::convert::acad_to_truck::TruckObject;
|
|
use crate::scene::convert::truck_tess;
|
|
use truck_modeling::builder;
|
|
use truck_modeling::{Point3, Rad, Vector3 as TruckVec3};
|
|
|
|
let entity_opt = self.tabs[i].scene.document.get_entity(handle).cloned();
|
|
if let Some(entity) = entity_opt {
|
|
let truck_entity = entity.to_truck_entity(&self.tabs[i].scene.document);
|
|
let result = truck_entity.and_then(|te| {
|
|
let wire: Option<truck_modeling::Wire> = match te.object {
|
|
TruckObject::Contour(w) => Some(w),
|
|
TruckObject::Curve(e) => Some(std::iter::once(e).collect()),
|
|
_ => None,
|
|
};
|
|
let wire = wire?;
|
|
let origin = Point3::new(
|
|
axis_start.x as f64,
|
|
axis_start.y as f64,
|
|
axis_start.z as f64,
|
|
);
|
|
let dir = (axis_end - axis_start).normalize();
|
|
let axis = TruckVec3::new(dir.x as f64, dir.y as f64, dir.z as f64);
|
|
let shell = builder::rsweep(
|
|
&wire,
|
|
origin,
|
|
axis,
|
|
Rad(angle_deg.to_radians() as f64),
|
|
);
|
|
match truck_tess::tessellate_shell(&shell) {
|
|
truck_tess::TruckTessResult::Mesh {
|
|
verts,
|
|
verts_low,
|
|
normals,
|
|
indices,
|
|
} => Some(crate::scene::model::mesh_model::MeshModel {
|
|
name: String::new(),
|
|
verts,
|
|
verts_low,
|
|
normals,
|
|
indices,
|
|
color,
|
|
selected: false,
|
|
}),
|
|
_ => None,
|
|
}
|
|
});
|
|
if let Some(mut mesh) = result {
|
|
self.push_undo_snapshot(i, "REVOLVE");
|
|
let new_entity = empty_solid3d();
|
|
let new_handle = self.tabs[i].scene.add_entity(new_entity);
|
|
mesh.name = format!("{}", new_handle.value());
|
|
self.tabs[i].scene.meshes.insert(new_handle, crate::scene::MeshLodSet::from_single(mesh));
|
|
self.tabs[i].dirty = true;
|
|
self.command_line
|
|
.push_output(&format!("REVOLVE: solid created ({:.0}°).", angle_deg));
|
|
} else {
|
|
self.command_line
|
|
.push_error("REVOLVE: could not revolve profile.");
|
|
}
|
|
} else {
|
|
self.command_line.push_error("REVOLVE: entity not found.");
|
|
}
|
|
self.tabs[i].active_cmd = None;
|
|
self.tabs[i].snap_result = None;
|
|
self.tabs[i].scene.clear_preview_wire();
|
|
self.restore_pre_cmd_tangent();
|
|
}
|
|
|
|
// ── SWEEP ──────────────────────────────────────────────────────
|
|
CmdResult::SweepEntity {
|
|
profile_handle,
|
|
path_handle,
|
|
color,
|
|
} => {
|
|
use crate::entities::traits::EntityTypeOps;
|
|
use crate::modules::insert::solid3d_cmds::empty_solid3d;
|
|
use crate::scene::convert::acad_to_truck::TruckObject;
|
|
use crate::scene::convert::truck_tess;
|
|
use truck_modeling::builder;
|
|
use truck_modeling::Vector3 as TruckVec3;
|
|
|
|
let profile_ent = self.tabs[i]
|
|
.scene
|
|
.document
|
|
.get_entity(profile_handle)
|
|
.cloned();
|
|
let path_ent = self.tabs[i].scene.document.get_entity(path_handle).cloned();
|
|
|
|
let result = profile_ent.zip(path_ent).and_then(|(prof_e, path_e)| {
|
|
let prof_truck = prof_e.to_truck_entity(&self.tabs[i].scene.document)?;
|
|
let path_truck = path_e.to_truck_entity(&self.tabs[i].scene.document)?;
|
|
|
|
// Profile must be a wire (closed or open).
|
|
let profile_wire: truck_modeling::Wire = match prof_truck.object {
|
|
TruckObject::Contour(w) => w,
|
|
TruckObject::Curve(e) => std::iter::once(e).collect(),
|
|
_ => return None,
|
|
};
|
|
|
|
// Path determines the sweep operation.
|
|
let mesh = match path_truck.object {
|
|
// Linear path: translate profile along the line direction.
|
|
TruckObject::Curve(edge) => {
|
|
let p_start = edge.front().point();
|
|
let p_end = edge.back().point();
|
|
let dir = TruckVec3::new(
|
|
p_end.x - p_start.x,
|
|
p_end.y - p_start.y,
|
|
p_end.z - p_start.z,
|
|
);
|
|
// Try to build a face from the profile; if it's a closed
|
|
// wire we get a Solid, otherwise a Shell.
|
|
if let Ok(face) = builder::try_attach_plane(&[profile_wire.clone()]) {
|
|
let solid = builder::tsweep(&face, dir);
|
|
match truck_tess::tessellate_solid(&solid) {
|
|
truck_tess::TruckTessResult::Mesh {
|
|
verts,
|
|
verts_low,
|
|
normals,
|
|
indices,
|
|
} => Some(crate::scene::model::mesh_model::MeshModel {
|
|
name: String::new(),
|
|
verts,
|
|
verts_low,
|
|
normals,
|
|
indices,
|
|
color,
|
|
selected: false,
|
|
}),
|
|
_ => None,
|
|
}
|
|
} else {
|
|
let shell = builder::tsweep(&profile_wire, dir);
|
|
match truck_tess::tessellate_shell(&shell) {
|
|
truck_tess::TruckTessResult::Mesh {
|
|
verts,
|
|
verts_low,
|
|
normals,
|
|
indices,
|
|
} => Some(crate::scene::model::mesh_model::MeshModel {
|
|
name: String::new(),
|
|
verts,
|
|
verts_low,
|
|
normals,
|
|
indices,
|
|
color,
|
|
selected: false,
|
|
}),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
// Contour path (polyline): sweep along the polyline using the
|
|
// first edge's direction as approximation (multi-segment sweep
|
|
// requires NURBS deformation — not supported here).
|
|
TruckObject::Contour(path_wire) => {
|
|
// Use start→end of the whole wire as translation vector.
|
|
let p_start = path_wire.front_vertex()?.point();
|
|
let p_end = path_wire.back_vertex()?.point();
|
|
let dir = TruckVec3::new(
|
|
p_end.x - p_start.x,
|
|
p_end.y - p_start.y,
|
|
p_end.z - p_start.z,
|
|
);
|
|
if let Ok(face) = builder::try_attach_plane(&[profile_wire.clone()]) {
|
|
let solid = builder::tsweep(&face, dir);
|
|
match truck_tess::tessellate_solid(&solid) {
|
|
truck_tess::TruckTessResult::Mesh {
|
|
verts,
|
|
verts_low,
|
|
normals,
|
|
indices,
|
|
} => Some(crate::scene::model::mesh_model::MeshModel {
|
|
name: String::new(),
|
|
verts,
|
|
verts_low,
|
|
normals,
|
|
indices,
|
|
color,
|
|
selected: false,
|
|
}),
|
|
_ => None,
|
|
}
|
|
} else {
|
|
let shell = builder::tsweep(&profile_wire, dir);
|
|
match truck_tess::tessellate_shell(&shell) {
|
|
truck_tess::TruckTessResult::Mesh {
|
|
verts,
|
|
verts_low,
|
|
normals,
|
|
indices,
|
|
} => Some(crate::scene::model::mesh_model::MeshModel {
|
|
name: String::new(),
|
|
verts,
|
|
verts_low,
|
|
normals,
|
|
indices,
|
|
color,
|
|
selected: false,
|
|
}),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
_ => None,
|
|
};
|
|
mesh
|
|
});
|
|
|
|
if let Some(mut mesh) = result {
|
|
self.push_undo_snapshot(i, "SWEEP");
|
|
let new_entity = empty_solid3d();
|
|
let new_handle = self.tabs[i].scene.add_entity(new_entity);
|
|
mesh.name = format!("{}", new_handle.value());
|
|
self.tabs[i].scene.meshes.insert(new_handle, crate::scene::MeshLodSet::from_single(mesh));
|
|
self.tabs[i].dirty = true;
|
|
self.command_line.push_output("SWEEP: solid created.");
|
|
} else {
|
|
self.command_line.push_error("SWEEP: could not sweep profile along path. Use a closed 2D profile and a Line or Polyline path.");
|
|
}
|
|
self.tabs[i].active_cmd = None;
|
|
self.tabs[i].snap_result = None;
|
|
self.tabs[i].scene.clear_preview_wire();
|
|
self.restore_pre_cmd_tangent();
|
|
}
|
|
|
|
// ── LOFT ───────────────────────────────────────────────────────
|
|
CmdResult::LoftEntities { handles, color } => {
|
|
use crate::entities::traits::EntityTypeOps;
|
|
use crate::modules::insert::solid3d_cmds::empty_solid3d;
|
|
use crate::scene::convert::acad_to_truck::TruckObject;
|
|
use crate::scene::convert::truck_tess;
|
|
use truck_modeling::builder;
|
|
|
|
// Collect wires from each profile.
|
|
let mut wires: Vec<truck_modeling::Wire> = Vec::new();
|
|
for h in &handles {
|
|
if let Some(ent) = self.tabs[i].scene.document.get_entity(*h).cloned() {
|
|
if let Some(te) = ent.to_truck_entity(&self.tabs[i].scene.document) {
|
|
let wire = match te.object {
|
|
TruckObject::Contour(w) => Some(w),
|
|
TruckObject::Curve(e) => Some(std::iter::once(e).collect()),
|
|
_ => None,
|
|
};
|
|
if let Some(w) = wire {
|
|
wires.push(w);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let result: Option<crate::scene::model::mesh_model::MeshModel> = (|| {
|
|
if wires.len() < 2 {
|
|
return None;
|
|
}
|
|
|
|
// Build ruled shells between consecutive profile pairs.
|
|
let mut all_faces: Vec<truck_modeling::Face> = Vec::new();
|
|
|
|
for pair in wires.windows(2) {
|
|
let shell = builder::try_wire_homotopy(&pair[0], &pair[1]).ok()?;
|
|
for face in shell.into_iter() {
|
|
all_faces.push(face);
|
|
}
|
|
}
|
|
|
|
// Cap the first and last profiles if they are closed.
|
|
if let Ok(cap) = builder::try_attach_plane(&[wires.first()?.clone()]) {
|
|
all_faces.push(cap);
|
|
}
|
|
if let Ok(cap) = builder::try_attach_plane(&[wires.last()?.clone()]) {
|
|
all_faces.push(cap);
|
|
}
|
|
|
|
let shell = truck_modeling::Shell::from(all_faces);
|
|
match truck_tess::tessellate_shell(&shell) {
|
|
truck_tess::TruckTessResult::Mesh {
|
|
verts,
|
|
verts_low,
|
|
normals,
|
|
indices,
|
|
} => Some(crate::scene::model::mesh_model::MeshModel {
|
|
name: String::new(),
|
|
verts,
|
|
verts_low,
|
|
normals,
|
|
indices,
|
|
color,
|
|
selected: false,
|
|
}),
|
|
_ => None,
|
|
}
|
|
})();
|
|
|
|
if let Some(mut mesh) = result {
|
|
self.push_undo_snapshot(i, "LOFT");
|
|
let new_entity = empty_solid3d();
|
|
let new_handle = self.tabs[i].scene.add_entity(new_entity);
|
|
mesh.name = format!("{}", new_handle.value());
|
|
self.tabs[i].scene.meshes.insert(new_handle, crate::scene::MeshLodSet::from_single(mesh));
|
|
self.tabs[i].dirty = true;
|
|
self.command_line.push_output(&format!(
|
|
"LOFT: solid created from {} profiles.",
|
|
handles.len()
|
|
));
|
|
} else {
|
|
self.command_line.push_error("LOFT: could not loft profiles. Ensure sections have the same edge count and are compatible.");
|
|
}
|
|
self.tabs[i].active_cmd = None;
|
|
self.tabs[i].snap_result = None;
|
|
self.tabs[i].scene.clear_preview_wire();
|
|
self.restore_pre_cmd_tangent();
|
|
}
|
|
|
|
CmdResult::HatcheditApply {
|
|
handle,
|
|
name,
|
|
scale,
|
|
angle,
|
|
} => {
|
|
if let Some(mut model) = self.tabs[i].scene.hatches.get(&handle).cloned() {
|
|
// Update model fields
|
|
if !name.is_empty() {
|
|
use crate::scene::model::hatch_model::HatchPattern;
|
|
use crate::scene::model::hatch_patterns;
|
|
model.name = name.clone();
|
|
if name.to_uppercase() == "SOLID" {
|
|
model.pattern = HatchPattern::Solid;
|
|
} else if let Some(entry) = hatch_patterns::find(&name) {
|
|
model.pattern = entry.gpu.clone();
|
|
}
|
|
// If not found in catalog, keep existing pattern type
|
|
}
|
|
model.scale = scale;
|
|
model.angle_offset = angle;
|
|
|
|
self.push_undo_snapshot(i, "HATCHEDIT");
|
|
// Remove old hatch (entity + GPU model)
|
|
self.tabs[i].scene.erase_entities(&[handle]);
|
|
// Re-add with updated model
|
|
self.tabs[i].scene.add_hatch(model);
|
|
self.tabs[i].dirty = true;
|
|
self.command_line.push_output("HATCHEDIT: hatch updated.");
|
|
} else {
|
|
self.command_line
|
|
.push_error("HATCHEDIT: hatch entity not found.");
|
|
}
|
|
self.tabs[i].active_cmd = None;
|
|
self.tabs[i].snap_result = None;
|
|
self.tabs[i].scene.clear_preview_wire();
|
|
self.restore_pre_cmd_tangent();
|
|
}
|
|
CmdResult::OpenMTextEditor {
|
|
pos,
|
|
handle,
|
|
initial,
|
|
height,
|
|
} => {
|
|
self.tabs[i].active_cmd = None;
|
|
self.tabs[i].snap_result = None;
|
|
self.open_mtext_editor(pos, handle, &initial, height);
|
|
}
|
|
CmdResult::OpenTextEditor {
|
|
pos,
|
|
handle,
|
|
initial,
|
|
height,
|
|
} => {
|
|
self.tabs[i].active_cmd = None;
|
|
self.tabs[i].snap_result = None;
|
|
self.open_text_inline(
|
|
pos,
|
|
handle,
|
|
&initial,
|
|
height,
|
|
super::text_inline::TextEntityField::Text,
|
|
);
|
|
}
|
|
CmdResult::EditTextEntity { handle } => {
|
|
self.tabs[i].active_cmd = None;
|
|
self.tabs[i].snap_result = None;
|
|
self.tabs[i].scene.clear_preview_wire();
|
|
self.restore_pre_cmd_tangent();
|
|
self.ribbon.deactivate_tool();
|
|
return self.begin_text_edit(handle);
|
|
}
|
|
CmdResult::SuspendForTextEdit { handle } => {
|
|
let is_editable = crate::app::text_inline::can_edit_text(handle, &self.tabs[i].scene.document);
|
|
if !is_editable {
|
|
self.command_line.push_error("TEXTEDIT: selected entity is not text.");
|
|
let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt());
|
|
if let Some(p) = prompt {
|
|
self.command_line.push_info(&p);
|
|
}
|
|
return Task::none();
|
|
}
|
|
let cmd = self.tabs[i].active_cmd.take();
|
|
self.tabs[i].suspended_cmd = cmd;
|
|
self.tabs[i].snap_result = None;
|
|
self.tabs[i].scene.clear_preview_wire();
|
|
self.restore_pre_cmd_tangent();
|
|
self.ribbon.deactivate_tool();
|
|
return self.begin_text_edit(handle);
|
|
}
|
|
CmdResult::UndoDocument => {
|
|
let active = self.tabs[i].active_cmd.take();
|
|
self.undo_active_tab();
|
|
self.tabs[i].active_cmd = active;
|
|
let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt());
|
|
if let Some(p) = prompt {
|
|
self.command_line.push_info(&p);
|
|
}
|
|
}
|
|
CmdResult::SetTexteditMode(val) => {
|
|
self.texteditmode = val;
|
|
let display_val = if val { 1 } else { 0 };
|
|
self.command_line.push_output(&format!("TEXTEDITMODE set to {display_val}"));
|
|
self.tabs[i].active_cmd = None;
|
|
self.tabs[i].snap_result = None;
|
|
self.tabs[i].scene.clear_preview_wire();
|
|
}
|
|
CmdResult::DdeditEntity { handle, new_text } => {
|
|
let mut updated = false;
|
|
let mut is_dim = false;
|
|
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) {
|
|
match entity {
|
|
acadrust::EntityType::Text(t) => {
|
|
t.value = new_text;
|
|
updated = true;
|
|
}
|
|
acadrust::EntityType::MText(t) => {
|
|
t.value = new_text;
|
|
updated = true;
|
|
}
|
|
acadrust::EntityType::AttributeDefinition(a) => {
|
|
a.default_value = new_text;
|
|
updated = true;
|
|
}
|
|
acadrust::EntityType::AttributeEntity(a) => {
|
|
a.set_value(new_text);
|
|
updated = true;
|
|
}
|
|
acadrust::EntityType::Dimension(d) => {
|
|
// Empty string resets to auto-measured value; otherwise set override.
|
|
let base = d.base_mut();
|
|
base.text = new_text;
|
|
updated = true;
|
|
is_dim = true;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
if is_dim {
|
|
// The edited override changed the dimension text; drop its
|
|
// stale *D block so save re-bakes it. (#181)
|
|
crate::modules::draw::modify::explode::invalidate_dim_block(
|
|
&mut self.tabs[i].scene.document,
|
|
handle,
|
|
);
|
|
}
|
|
if updated {
|
|
self.push_undo_snapshot(i, "DDEDIT");
|
|
self.tabs[i].dirty = true;
|
|
self.command_line.push_output("DDEDIT: text updated.");
|
|
} else {
|
|
self.command_line
|
|
.push_error("DDEDIT: entity type not supported.");
|
|
}
|
|
self.tabs[i].active_cmd = None;
|
|
self.tabs[i].snap_result = None;
|
|
self.tabs[i].scene.clear_preview_wire();
|
|
self.restore_pre_cmd_tangent();
|
|
}
|
|
}
|
|
// Keep the command-line input focused at all times — every typed
|
|
// character is meant to route there (the command processor reads
|
|
// its keystroke stream from this widget). When no command is
|
|
// running the ribbon tool button still has to visually deactivate.
|
|
if self.tabs[i].active_cmd.is_none() {
|
|
self.ribbon.deactivate_tool();
|
|
}
|
|
// The in-place TEXT editor needs keyboard focus on its own field.
|
|
if self.text_inline.is_some() {
|
|
return iced::widget::operation::focus(iced::widget::Id::new(
|
|
super::view::TEXT_INLINE_ID,
|
|
));
|
|
}
|
|
self.focus_cmd_input()
|
|
}
|
|
|
|
/// Restore the tangent-snap / ortho state that was in effect before the command started.
|
|
/// Recreate clipboard-dependency records (layer / linetype / text + dim
|
|
/// style) in tab `i`'s document for any the copied entities reference but
|
|
/// this drawing doesn't already have. Each recreated record gets a fresh
|
|
/// handle from the target document so it can't collide with an existing
|
|
/// one. No-op for same-document pastes (the records already exist). (#129)
|
|
pub(super) fn merge_clipboard_deps(&mut self, i: usize) {
|
|
use acadrust::TableEntry;
|
|
if self.clipboard_deps.is_empty() {
|
|
return;
|
|
}
|
|
let doc = &mut self.tabs[i].scene.document;
|
|
for rec in &self.clipboard_deps.layers {
|
|
if !doc.layers.contains(rec.name()) {
|
|
let mut r = rec.clone();
|
|
r.set_handle(doc.allocate_handle());
|
|
let _ = doc.layers.add(r);
|
|
}
|
|
}
|
|
for rec in &self.clipboard_deps.linetypes {
|
|
if !doc.line_types.contains(rec.name()) {
|
|
let mut r = rec.clone();
|
|
r.set_handle(doc.allocate_handle());
|
|
let _ = doc.line_types.add(r);
|
|
}
|
|
}
|
|
for rec in &self.clipboard_deps.text_styles {
|
|
if !doc.text_styles.contains(rec.name()) {
|
|
let mut r = rec.clone();
|
|
r.set_handle(doc.allocate_handle());
|
|
let _ = doc.text_styles.add(r);
|
|
}
|
|
}
|
|
for rec in &self.clipboard_deps.dim_styles {
|
|
if !doc.dim_styles.contains(rec.name()) {
|
|
let mut r = rec.clone();
|
|
r.set_handle(doc.allocate_handle());
|
|
let _ = doc.dim_styles.add(r);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Recreate any block definition the pasted INSERTs reference but tab
|
|
/// `i`'s document lacks (cross-drawing paste), so the block reference
|
|
/// renders its geometry instead of nothing. No-op for same-document
|
|
/// pastes. (#135)
|
|
pub(super) fn merge_clipboard_blocks(&mut self, i: usize) {
|
|
if self.clipboard_deps.blocks.is_empty() {
|
|
return;
|
|
}
|
|
let blocks = self.clipboard_deps.blocks.clone();
|
|
for def in blocks {
|
|
if self.tabs[i].scene.document.block_records.get(&def.name).is_some() {
|
|
continue;
|
|
}
|
|
self.tabs[i]
|
|
.scene
|
|
.define_block_raw(&def.name, def.base_point, def.entities);
|
|
}
|
|
}
|
|
|
|
/// Shared paste finalize for every paste path (PASTECLIP, PASTEORIG):
|
|
/// recreate the clipboard's dependency records and block definitions, add
|
|
/// each entity with fresh handles (optionally transformed), recreate each
|
|
/// entity's xdictionary graph (XCLIP filters etc.), and tessellate pasted
|
|
/// solids. Returns the new handles, index-aligned with the clipboard
|
|
/// (NULL where an add failed). Keeping this in one place means a new
|
|
/// cross-drawing concern is wired once, not re-implemented per command.
|
|
pub(super) fn finalize_paste(
|
|
&mut self,
|
|
i: usize,
|
|
translate: Option<crate::command::EntityTransform>,
|
|
) -> Vec<Handle> {
|
|
self.merge_clipboard_deps(i);
|
|
self.merge_clipboard_blocks(i);
|
|
let by_index: Vec<Handle> = self
|
|
.clipboard
|
|
.clone()
|
|
.into_iter()
|
|
.map(|mut entity| {
|
|
if let Some(t) = &translate {
|
|
crate::scene::view::dispatch::apply_transform(&mut entity, t);
|
|
}
|
|
// A dimension draws from its baked `*D` block (baked in WCS), so
|
|
// give the paste its own transformed copy of that block and
|
|
// re-point it — otherwise the pasted dimension renders at the
|
|
// source location instead of the paste point. The block was
|
|
// snapshotted into the clipboard at copy time, so this works
|
|
// cross-drawing too. Mirrors the in-drawing copy. (#290, #161)
|
|
if let acadrust::EntityType::Dimension(d) = &entity {
|
|
let bn = d.base().block_name.clone();
|
|
if !bn.trim().is_empty() {
|
|
let subs = self
|
|
.clipboard_deps
|
|
.dim_blocks
|
|
.iter()
|
|
.find(|b| b.name.eq_ignore_ascii_case(&bn))
|
|
.map(|def| def.entities.clone());
|
|
if let Some(subs) = subs {
|
|
let bt = translate.clone().unwrap_or(
|
|
crate::command::EntityTransform::Translate(glam::DVec3::ZERO),
|
|
);
|
|
if let Some(new_bn) =
|
|
self.tabs[i].scene.define_transformed_block(&subs, &bt)
|
|
{
|
|
if let acadrust::EntityType::Dimension(d) = &mut entity {
|
|
d.base_mut().block_name = new_bn;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
self.tabs[i].scene.add_entity_clone(entity)
|
|
})
|
|
.collect();
|
|
self.merge_clipboard_ext_objects(i, &by_index);
|
|
// Incremental: `add_entity` already tessellated every pasted top-level
|
|
// solid, and existing document solids are still cached — so only newly
|
|
// introduced block-definition solids need building. The full rebuild
|
|
// would clear and re-tessellate the entire document (every solid in the
|
|
// drawing) on each paste, which is what made a large paste stall.
|
|
self.tabs[i].scene.populate_missing_meshes_from_document();
|
|
by_index
|
|
}
|
|
|
|
/// Recreate the extension-dictionary object graph (XCLIP spatial filters,
|
|
/// attached XRecords, …) captured for each copied entity, cloning every
|
|
/// object into this document with fresh handles, remapping all internal
|
|
/// references, and re-pointing the pasted entity's `xdictionary_handle` at
|
|
/// the new root. `by_index` is the paste's new entity handles, aligned with
|
|
/// the clipboard order (NULL where the add failed). No-op without captures.
|
|
pub(super) fn merge_clipboard_ext_objects(&mut self, i: usize, by_index: &[Handle]) {
|
|
if self.clipboard_deps.ext_objects.is_empty() {
|
|
return;
|
|
}
|
|
let captures = self.clipboard_deps.ext_objects.clone();
|
|
let doc = &mut self.tabs[i].scene.document;
|
|
for cap in &captures {
|
|
let Some(&new_entity) = by_index.get(cap.entity_index) else {
|
|
continue;
|
|
};
|
|
if new_entity.is_null() {
|
|
continue;
|
|
}
|
|
if let Some(new_root) = recreate_ext_subtree(doc, cap, Some(new_entity)) {
|
|
if let Some(e) = doc.get_entity_mut(new_entity) {
|
|
e.common_mut().xdictionary_handle = Some(new_root);
|
|
}
|
|
}
|
|
}
|
|
// The wires were tessellated before the filters existed; force a rebuild
|
|
// so the clip is applied to the freshly-pasted, now-filtered inserts.
|
|
self.tabs[i].scene.bump_geometry();
|
|
}
|
|
|
|
/// Recreate the captured xdictionary subtrees in this document (fresh
|
|
/// handles, remapped references) WITHOUT an added host entity, returning
|
|
/// `entity_index → new xdictionary root`. Used by PASTEBLOCK, which folds
|
|
/// the clipboard into a new block definition: the caller stamps each new
|
|
/// root onto the matching entity's `xdictionary_handle` before defining the
|
|
/// block, so the block's nested insert keeps its XCLIP filter.
|
|
pub(super) fn recreate_clipboard_ext_roots(
|
|
&mut self,
|
|
i: usize,
|
|
) -> std::collections::HashMap<usize, Handle> {
|
|
let mut out = std::collections::HashMap::new();
|
|
if self.clipboard_deps.ext_objects.is_empty() {
|
|
return out;
|
|
}
|
|
let captures = self.clipboard_deps.ext_objects.clone();
|
|
let doc = &mut self.tabs[i].scene.document;
|
|
for cap in &captures {
|
|
if let Some(new_root) = recreate_ext_subtree(doc, cap, None) {
|
|
out.insert(cap.entity_index, new_root);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
fn restore_pre_cmd_tangent(&mut self) {
|
|
if let Some(was_on) = self.pre_cmd_tangent.take() {
|
|
if !was_on {
|
|
self.snapper.enabled.remove(&crate::snap::SnapType::Tangent);
|
|
}
|
|
}
|
|
if self.rect_suppressed_ortho {
|
|
self.rect_suppressed_ortho = false;
|
|
self.ortho_mode = true;
|
|
self.polar_mode = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Clone one captured xdictionary subtree into `doc` with fresh handles,
|
|
/// remapping every internal reference (and the owning entity, when known),
|
|
/// returning the new root handle. `allocate_handle` advances the document's
|
|
/// handle counter — `next_handle()` only peeks, so reusing it would hand every
|
|
/// object the same handle and collapse the dictionary chain.
|
|
fn recreate_ext_subtree(
|
|
doc: &mut acadrust::CadDocument,
|
|
cap: &crate::app::ClipExtObjects,
|
|
entity_handle: Option<Handle>,
|
|
) -> Option<Handle> {
|
|
use std::collections::HashMap;
|
|
let mut remap: HashMap<Handle, Handle> = HashMap::new();
|
|
if let Some(eh) = entity_handle {
|
|
remap.insert(cap.src_entity_handle, eh);
|
|
}
|
|
for (old, _) in &cap.objects {
|
|
remap.insert(*old, doc.allocate_handle());
|
|
}
|
|
for (old, obj) in &cap.objects {
|
|
let mut obj = obj.clone();
|
|
let new_h = remap[old];
|
|
remap_object(&mut obj, new_h, &remap);
|
|
doc.objects.insert(new_h, obj);
|
|
}
|
|
remap.get(&cap.root).copied()
|
|
}
|
|
|
|
/// Rewrite a cloned extension-dictionary object onto fresh handles: set its own
|
|
/// handle to `new_handle` and remap its owner and any handle references it holds
|
|
/// through `remap` (a handle still in the source space stays unchanged, which is
|
|
/// correct for cross-references that point outside the captured subtree).
|
|
fn remap_object(
|
|
obj: &mut acadrust::objects::ObjectType,
|
|
new_handle: acadrust::Handle,
|
|
remap: &std::collections::HashMap<acadrust::Handle, acadrust::Handle>,
|
|
) {
|
|
use acadrust::objects::ObjectType;
|
|
let map = |h: acadrust::Handle| remap.get(&h).copied().unwrap_or(h);
|
|
match obj {
|
|
ObjectType::Dictionary(d) => {
|
|
d.handle = new_handle;
|
|
d.owner = map(d.owner);
|
|
for (_, h) in d.entries.iter_mut() {
|
|
*h = map(*h);
|
|
}
|
|
if let Some(x) = d.xdictionary_handle.as_mut() {
|
|
*x = map(*x);
|
|
}
|
|
for r in d.reactors.iter_mut() {
|
|
*r = map(*r);
|
|
}
|
|
}
|
|
ObjectType::DictionaryWithDefault(d) => {
|
|
d.handle = new_handle;
|
|
d.owner = map(d.owner);
|
|
for (_, h) in d.entries.iter_mut() {
|
|
*h = map(*h);
|
|
}
|
|
d.default_handle = map(d.default_handle);
|
|
}
|
|
ObjectType::DictionaryVariable(v) => {
|
|
v.handle = new_handle;
|
|
v.owner_handle = map(v.owner_handle);
|
|
}
|
|
ObjectType::SpatialFilter(s) => {
|
|
s.handle = new_handle;
|
|
s.owner = map(s.owner);
|
|
}
|
|
ObjectType::XRecord(x) => {
|
|
x.handle = new_handle;
|
|
x.owner = map(x.owner);
|
|
}
|
|
ObjectType::Group(g) => {
|
|
g.handle = new_handle;
|
|
g.owner = map(g.owner);
|
|
for h in g.entities.iter_mut() {
|
|
*h = map(*h);
|
|
}
|
|
}
|
|
// Other leaf object kinds don't appear in an entity xdictionary; if one
|
|
// does, it's inserted with the fresh handle below via the caller's key,
|
|
// but its internal owner is left as-is (best effort).
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
// ── DIMSPACE helper ───────────────────────────────────────────────────────────
|
|
|
|
/// Parse `base_val,h1;h2;...;hN,spacing` and adjust parallel dimension positions.
|
|
fn apply_dimspace(scene: &mut crate::scene::Scene, encoded: &str) {
|
|
// Format: "<base_handle>,<h1>;<h2>;...;<hN>,<spacing>"
|
|
let parts: Vec<&str> = encoded.splitn(3, ',').collect();
|
|
if parts.len() < 3 {
|
|
return;
|
|
}
|
|
let base_val: u64 = parts[0].parse().unwrap_or(0);
|
|
let other_vals: Vec<u64> = parts[1]
|
|
.split(';')
|
|
.filter_map(|s| s.parse::<u64>().ok())
|
|
.collect();
|
|
let spacing: f64 = parts[2].parse().unwrap_or(0.0);
|
|
|
|
use acadrust::entities::Dimension;
|
|
let base_h = acadrust::Handle::from(base_val);
|
|
// Base dim: the perpendicular direction (from its rotation / axis) and the
|
|
// dim line's perpendicular coordinate. Spacing steps each parallel dim along
|
|
// this perp IN THE DRAWING PLANE — offsetting Z had no effect on the dim
|
|
// line, which is computed from def·perp with perp.z = 0. (#181 / DIM-021)
|
|
let (perp, base_coord) = match scene.document.get_entity(base_h) {
|
|
Some(acadrust::EntityType::Dimension(Dimension::Linear(d))) => {
|
|
let (s, c) = d.rotation.sin_cos();
|
|
let perp = (-s, c);
|
|
let dp = d.definition_point;
|
|
(perp, dp.x * perp.0 + dp.y * perp.1)
|
|
}
|
|
Some(acadrust::EntityType::Dimension(Dimension::Aligned(d))) => {
|
|
let dx = d.second_point.x - d.first_point.x;
|
|
let dy = d.second_point.y - d.first_point.y;
|
|
let len = (dx * dx + dy * dy).sqrt().max(1e-12);
|
|
let perp = (-dy / len, dx / len);
|
|
let dp = d.definition_point;
|
|
(perp, dp.x * perp.0 + dp.y * perp.1)
|
|
}
|
|
_ => return,
|
|
};
|
|
|
|
let effective_spacing = if spacing <= 0.0 { 10.0 } else { spacing };
|
|
for (idx, &hv) in other_vals.iter().enumerate() {
|
|
let h = acadrust::Handle::from(hv);
|
|
let target = base_coord + effective_spacing * (idx + 1) as f64;
|
|
if let Some(acadrust::EntityType::Dimension(dim)) = scene.document.get_entity_mut(h) {
|
|
// Slide this dim's definition point along perp so its perpendicular
|
|
// coordinate equals `target`; update both the struct field (render)
|
|
// and base (save).
|
|
let slide = |p: &mut acadrust::types::Vector3| {
|
|
let cur = p.x * perp.0 + p.y * perp.1;
|
|
let delta = target - cur;
|
|
p.x += perp.0 * delta;
|
|
p.y += perp.1 * delta;
|
|
};
|
|
match dim {
|
|
Dimension::Linear(d) => {
|
|
slide(&mut d.definition_point);
|
|
d.base.definition_point = d.definition_point;
|
|
}
|
|
Dimension::Aligned(d) => {
|
|
slide(&mut d.definition_point);
|
|
d.base.definition_point = d.definition_point;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
// The dimension line moved, so its baked *D block is stale — drop it so
|
|
// the next save re-bakes it (no-op for non-dimensions). (#181)
|
|
crate::modules::draw::modify::explode::invalidate_dim_block(&mut scene.document, h);
|
|
}
|
|
scene.bump_geometry();
|
|
}
|
|
|
|
// ── MLEADERALIGN helper ───────────────────────────────────────────────────────
|
|
|
|
/// Parse `h1,h2,...;fx,fz;tx,tz` and align multileader content points along the direction.
|
|
fn apply_mleader_align(scene: &mut crate::scene::Scene, encoded: &str) {
|
|
// Format: "<h1>,<h2>,...;<fx>,<fz>;<tx>,<tz>"
|
|
let parts: Vec<&str> = encoded.splitn(3, ';').collect();
|
|
if parts.len() < 3 {
|
|
return;
|
|
}
|
|
let handles: Vec<acadrust::Handle> = parts[0]
|
|
.split(',')
|
|
.filter_map(|s| s.parse::<u64>().ok().map(acadrust::Handle::from))
|
|
.collect();
|
|
let from_parts: Vec<f64> = parts[1].split(',').filter_map(|s| s.parse().ok()).collect();
|
|
let to_parts: Vec<f64> = parts[2].split(',').filter_map(|s| s.parse().ok()).collect();
|
|
if from_parts.len() < 2 || to_parts.len() < 2 || handles.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let fx = from_parts[0];
|
|
let fz = from_parts[1];
|
|
let tx = to_parts[0];
|
|
let tz = to_parts[1];
|
|
let dx = tx - fx;
|
|
let dz = tz - fz;
|
|
let len = (dx * dx + dz * dz).sqrt();
|
|
if len < 1e-9 {
|
|
return;
|
|
}
|
|
|
|
// Project each multileader's content point onto the alignment line, then
|
|
// snap it to the line (preserve perpendicular offset from line is discarded;
|
|
// align along direction through `from`).
|
|
for h in handles {
|
|
if let Some(acadrust::EntityType::MultiLeader(ml)) = scene.document.get_entity_mut(h) {
|
|
let cp = &mut ml.context.content_base_point;
|
|
// Project onto line from_pt + t * dir: keep t component, set perpendicular = 0
|
|
let rel_x = cp.x - fx;
|
|
let rel_z = cp.z - fz;
|
|
let t = (rel_x * (dx / len) + rel_z * (dz / len)) / len;
|
|
let t = t.clamp(0.0, 1.0);
|
|
cp.x = fx + t * dx;
|
|
cp.z = fz + t * dz;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── MLEADERCOLLECT helper ─────────────────────────────────────────────────────
|
|
|
|
/// Parse `h1,h2,...;px,pz` — merge all selected multileaders into the first one at position.
|
|
fn apply_mleader_collect(scene: &mut crate::scene::Scene, encoded: &str) {
|
|
let parts: Vec<&str> = encoded.splitn(2, ';').collect();
|
|
if parts.len() < 2 {
|
|
return;
|
|
}
|
|
let handles: Vec<acadrust::Handle> = parts[0]
|
|
.split(',')
|
|
.filter_map(|s| s.parse::<u64>().ok().map(acadrust::Handle::from))
|
|
.collect();
|
|
let pos_parts: Vec<f64> = parts[1].split(',').filter_map(|s| s.parse().ok()).collect();
|
|
if handles.len() < 2 || pos_parts.len() < 2 {
|
|
return;
|
|
}
|
|
|
|
let px = pos_parts[0];
|
|
let pz = pos_parts[1];
|
|
|
|
// Collect all leader roots from the secondary multileaders.
|
|
let mut extra_roots: Vec<acadrust::entities::LeaderRoot> = Vec::new();
|
|
for &h in &handles[1..] {
|
|
if let Some(acadrust::EntityType::MultiLeader(ml)) = scene.document.get_entity(h) {
|
|
extra_roots.extend(ml.context.leader_roots.iter().cloned());
|
|
}
|
|
}
|
|
|
|
// Add collected roots to the first multileader and move its content point.
|
|
if let Some(acadrust::EntityType::MultiLeader(ml)) = scene.document.get_entity_mut(handles[0]) {
|
|
ml.context.content_base_point.x = px;
|
|
ml.context.content_base_point.z = pz;
|
|
for root in extra_roots {
|
|
ml.context.leader_roots.push(root);
|
|
}
|
|
}
|
|
|
|
// Erase the secondary multileaders.
|
|
scene.erase_entities(&handles[1..]);
|
|
}
|