Files
OneCAD-Draft/src/app/command_driver.rs
T
Hakan SevenandClaude Fable 5 90fd69df06 feat(matchprop): match every style-affecting property (#281)
MATCHPROP copied only the general properties (and text style/height),
so matching dimension-to-dimension or hatch-to-hatch looked like it did
nothing — the visible differences live in type-specific style fields.

General set gains transparency. match_special_props transfers every
STYLE-affecting field the destination supports — never content or
placement:

- Text: style, height, width factor, oblique (style+height cross
  TEXT <-> MTEXT); MText adds line spacing, drawing direction and the
  background fill block.
- Dimension / Leader / Tolerance: the dim style name crosses all
  three, and the ACAD_DSTYLE override record is replicated through
  set_entity_xdata (cleared on the destination when the source carries
  none, so the match is exact). Restyled dimensions drop their baked
  *D block and repaint immediately.
- Hatch: pattern definition, type, angle, scale, solid flag and
  gradient — the boundary stays.
- LwPolyline: constant width (vertex widths included) and linetype
  generation.
- MultiLeader: style plus all style overrides (path type, line
  color/type/weight, landing/dogleg, arrowhead, text style/color/
  frame/height, overall scale, override flags); leader geometry, text
  and block content stay.

Closes #281

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:24:53 +03:00

2919 lines
138 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 shift = self.shift_down;
let result: Option<CmdResult> = {
let Some(cmd) = self.tabs[i].active_cmd.as_mut() else {
return Task::none();
};
cmd.set_ctrl(ctrl);
cmd.set_shift(shift);
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())
{
// Option keywords take precedence over the handle reading —
// "F"/"C"/"E" are valid hex, but during TRIM/EXTEND they are the
// Fence/Crossing/Edge options (#336). Only an unconsumed token
// falls through to the handle interpretation.
let consumed = self.tabs[i]
.active_cmd
.as_mut()
.and_then(|c| c.on_text_input(token));
if let Some(r) = consumed {
let _ = self.apply_cmd_result(r);
return;
}
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::RemoveLiveEntity(handle) => {
// The command backed off below a valid entity (PLINE Undo at
// one remaining vertex): take the live entity out of the
// document and keep prompting. No undo bookkeeping — the
// create's snapshot already covers the whole in-progress
// object as one unit.
self.tabs[i].scene.erase_entities(&[handle]);
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::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,
)
});
// Special (type-specific) properties travel like AutoCAD's
// Special Properties: each is captured from the source when it
// carries it and applied only to destinations that support it
// (#281). Text formatting crosses TEXT ↔ MTEXT (#361); the dim
// style crosses Dimension / Leader / Tolerance.
let src_clone = self.tabs[i].scene.document.get_entity(src).cloned();
let transparency = src_clone.as_ref().map(|e| e.common().transparency.clone());
// Dimension-style OVERRIDES ride the ACAD_DSTYLE xdata record;
// matching replicates the source's record (or clears the
// destination's when the source has none).
let dstyle_xdata: Option<Vec<acadrust::xdata::XDataValue>> = src_clone
.as_ref()
.filter(|e| {
matches!(
e,
acadrust::EntityType::Dimension(_) | acadrust::EntityType::Leader(_)
)
})
.and_then(|e| e.common().extended_data.get_record("ACAD_DSTYLE"))
.map(|r| r.values.clone());
if let Some((layer, color, linetype, lt_scale, lw)) = props {
self.push_undo_snapshot(i, "MATCHPROP");
let mut any_dim = false;
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(t) = &transparency {
e.common_mut().transparency = t.clone();
}
if let Some(se) = &src_clone {
if matches!(e, acadrust::EntityType::Dimension(_)) {
any_dim = true;
}
match_special_props(se, e);
}
}
// Dim-style overrides (ACAD_DSTYLE) follow the style
// for dimension / leader destinations — through
// set_entity_xdata so no stale raw record survives.
if dstyle_xdata.is_some()
|| matches!(
self.tabs[i].scene.document.get_entity(*h),
Some(
acadrust::EntityType::Dimension(_)
| acadrust::EntityType::Leader(_)
)
)
{
if matches!(
self.tabs[i].scene.document.get_entity(*h),
Some(
acadrust::EntityType::Dimension(_)
| acadrust::EntityType::Leader(_)
)
) && matches!(
src_clone,
Some(
acadrust::EntityType::Dimension(_)
| acadrust::EntityType::Leader(_)
)
) {
crate::scene::view::dispatch::set_entity_xdata(
&mut self.tabs[i].scene.document,
*h,
"ACAD_DSTYLE",
dstyle_xdata.clone(),
);
}
}
// A restyled dimension renders from its baked *D block —
// drop the stale block so the new style shows (#398).
if any_dim {
crate::modules::draw::modify::explode::invalidate_dim_block(
&mut self.tabs[i].scene.document,
*h,
);
}
// Hatch fills render from a prebuilt model (#415).
self.tabs[i].scene.refresh_fill_model(*h);
}
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, convert_to_polyline, PeditOp,
};
match &op {
// The convert replaces the entity (new handle).
PeditOp::ConvertToPolyline => {
let converted = self.tabs[i]
.scene
.document
.get_entity(handle)
.and_then(convert_to_polyline);
match converted {
Some(pl) => {
return self.apply_cmd_result(CmdResult::ReplaceEntity(
handle,
vec![pl],
));
}
None => self
.command_line
.push_error("PEDIT: cannot convert this entity."),
}
}
_ => {
// Snapshot BEFORE the mutation — an after-the-fact
// snapshot records the changed state and undo no-ops.
self.push_undo_snapshot(i, "PEDIT");
let changed = self.tabs[i]
.scene
.document
.get_entity_mut(handle)
.map(|e| apply_pedit(e, &op))
.unwrap_or(false);
if changed {
self.tabs[i].dirty = true;
// Repaint immediately (wide-band fill included).
self.tabs[i].scene.mark_entity_dirty(handle);
self.tabs[i].scene.refresh_fill_model(handle);
self.tabs[i].scene.bump_geometry_no_blocks();
self.command_line.push_output("PEDIT: applied.");
self.refresh_properties();
} else {
let _ = self.tabs[i].history.undo_stack.pop();
self.command_line
.push_error("PEDIT: operation not applicable to this entity.");
}
}
}
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::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 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..]);
}
/// MATCHPROP special properties (#281): copy every STYLE-affecting field from
/// `src` to `dst` when the destination supports it — never content (text
/// strings, block names) or placement (positions, rotations of the object
/// itself). Text formatting crosses TEXT ↔ MTEXT; the dimension style crosses
/// Dimension / Leader / Tolerance.
fn match_special_props(src: &acadrust::EntityType, dst: &mut acadrust::EntityType) {
use acadrust::EntityType as E;
// Text-ish source formatting.
let text_fmt = match src {
E::Text(t) => Some((t.style.clone(), t.height, t.width_factor, t.oblique_angle)),
E::MText(m) => Some((m.style.clone(), m.height, 1.0, 0.0)),
_ => None,
};
if let Some((style, height, wf, ob)) = text_fmt {
match dst {
E::Text(t) => {
t.style = style;
t.height = height;
if matches!(src, E::Text(_)) {
t.width_factor = wf;
t.oblique_angle = ob;
}
}
E::MText(m) => {
m.style = style;
m.height = height;
}
_ => {}
}
}
// MText-only extras (line spacing, background fill).
if let (E::MText(sm), E::MText(dm)) = (src, dst as &mut E) {
dm.drawing_direction = sm.drawing_direction;
dm.line_spacing_factor = sm.line_spacing_factor;
dm.line_spacing_style = sm.line_spacing_style;
dm.background_fill_flags = sm.background_fill_flags;
dm.background_scale = sm.background_scale;
dm.background_color = sm.background_color;
dm.background_transparency = sm.background_transparency;
}
// Dimension style name crosses the three dim-styled families.
let dim_style = match src {
E::Dimension(d) => Some(d.base().style_name.clone()),
E::Leader(l) => Some(l.dimension_style.clone()),
E::Tolerance(t) => Some(t.dimension_style_name.clone()),
_ => None,
};
if let Some(ds) = dim_style {
match dst {
E::Dimension(d) => d.base_mut().style_name = ds,
E::Leader(l) => l.dimension_style = ds,
E::Tolerance(t) => t.dimension_style_name = ds,
_ => {}
}
}
// Hatch pattern / gradient — everything but the boundary.
if let (E::Hatch(sh), E::Hatch(dh)) = (src, dst as &mut E) {
dh.pattern = sh.pattern.clone();
dh.pattern_type = sh.pattern_type;
dh.pattern_angle = sh.pattern_angle;
dh.pattern_scale = sh.pattern_scale;
dh.is_solid = sh.is_solid;
dh.gradient_color = sh.gradient_color.clone();
}
// Polyline display style: width + linetype generation.
if let (E::LwPolyline(sp), E::LwPolyline(dp)) = (src, dst as &mut E) {
dp.constant_width = sp.constant_width;
dp.plinegen = sp.plinegen;
for v in &mut dp.vertices {
v.start_width = sp.constant_width;
v.end_width = sp.constant_width;
}
}
// MultiLeader style + every style-affecting override; content and
// geometry (leader points, text, block handle) stay.
if let (E::MultiLeader(sm), E::MultiLeader(dm)) = (src, dst as &mut E) {
dm.style_handle = sm.style_handle;
dm.path_type = sm.path_type;
dm.line_color = sm.line_color;
dm.line_type_handle = sm.line_type_handle;
dm.line_weight = sm.line_weight;
dm.enable_landing = sm.enable_landing;
dm.enable_dogleg = sm.enable_dogleg;
dm.dogleg_length = sm.dogleg_length;
dm.arrowhead_handle = sm.arrowhead_handle;
dm.arrowhead_size = sm.arrowhead_size;
dm.text_style_handle = sm.text_style_handle;
dm.text_color = sm.text_color;
dm.text_frame = sm.text_frame;
dm.text_height = sm.text_height;
dm.scale_factor = sm.scale_factor;
dm.property_override_flags = sm.property_override_flags;
}
}