Files
OneCAD-Draft/src/app/command_driver.rs
T
Christopher OverbeckandClaude Fable 5.1 be7ba3d68e Record viewport associations for new dimensions
Removes PR3's `viewport_frame` seam in `viewport_ref.rs`; the frame is
PR1's camera-derived one everywhere.

The five dimension commands (DIMLINEAR / DIMALIGNED / DIMRADIUS /
DIMDIAMETER / DIMANGULAR) now record a real association when they
measure through a viewport. The commit path's one association gate
(`dimension_association_allowed`, added when PR2 was stacked) gains an
else-branch: where PR2 committed the dimension unassociated and PR1's
inference guard returned an empty source list, `Scene::attach_viewport_
dimension_association` now stores the whole chain — dimension ->
viewport -> block path -> entity -> feature — from the AcceptedSnaps PR1
captured, in definition-point slot order.

* `Scene::dimension_association_slot_points` names that order, including
  the radial dimensions `dimension_reference_points` does not cover.
* `OpenCADStudio::viewport_dimension_snaps` builds the slot array. A snap
  whose paper point is no longer the definition point it fills has its
  model point recomputed from that definition point through the frame:
  the object pick records one click for both extension origins, and a
  rotated DIMLINEAR projects its origins onto the measuring axis.
* Radial dimensions take their own path inside
  `attach_viewport_dimension_association`. Their single reference is an
  osnap type 10 carrying the radial marker (which circle / which polyline
  arc segment) and the angle around it; `source_reference` would hand
  back the generic curve-parameter marker -2, which
  `radial_source_for_marker` cannot find.

One measurement rule, documented in `viewport_ref::measurement_rule`:
`actual_measurement` is always the RAW measurement in the dimension's own
space (paper, for a viewport dimension), and the viewport compensation
lives only in the magnitude of the negative DIMLFAC override, applied
once by the formatter. PR2's persistence already followed it; PR3's
refresh did not — it substituted the model distance times
`model_factor()`, which made a refreshed dimension read `user_lfac` times
too much versus an identical freshly-created one. Refresh now writes the
paper measurement (a debug assert pins resolved paper points to the model
points through the frame), and `axis_distance` goes with it.

Block sub-entity snaps: verified that `block_cache` stamps the top-level
INSERT's handle onto every expanded and clipped sub-wire, so
`wire_source` can only ever report the INSERT — nested inserts included.
`OpenCADStudio::resolve_source_block_path` now descends from that INSERT
at accept time (using the object-pick path's existing
`resolve_measurable_entity`, so arbitrary depth, not just one level) and
fills `SnapSourceRef.block_path` with the real inner entity. Where the
descent cannot identify one — a non-planar-pickable type such as text,
hatch, spline or solid, or nesting past eight levels — the source stays
the INSERT with an empty path, which is coarser but never wrong. Both the
limitation and where it bites are documented on
`Scene::resolve_block_snap_source` and `SnapResult::source`.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-15 12:52:00 -05:00

7861 lines
362 KiB
Rust

use super::{Message, OpenCADStudio};
use crate::command::{CmdResult, SelectionEntity, StepInput};
use acadrust::Handle;
use iced::Task;
impl OpenCADStudio {
pub(super) fn reject_locked_edit(&mut self, i: usize, handle: Handle) -> bool {
let Some(layer) = self.tabs[i].scene.locked_layer_name(handle) else {
return false;
};
self.command_line.push_info(crate::tf!(
"Object is on locked layer \"{layer}\" — unlock the layer to edit it."
).as_ref());
true
}
pub(super) fn refresh_area_preview(&mut self, i: usize) {
let hatches = self.tabs[i]
.active_cmd
.as_ref()
.and_then(|command| command.hatch_preview_models());
if let Some(hatches) = hatches {
self.tabs[i].scene.set_command_preview_hatches(hatches);
return;
}
let regions = self.tabs[i]
.active_cmd
.as_ref()
.and_then(|command| command.area_preview_regions());
if let Some(regions) = regions {
self.tabs[i].scene.set_area_preview_regions(&regions);
}
}
/// Point supplied by a bare Enter before LINE/PLINE's first click. Prefer
/// the current endpoint of the most recently created path drawable in the
/// active space. A loaded drawing has no runtime anchor, so recover its
/// newest line/arc/polyline endpoint. An empty space starts at the active
/// UCS origin (0,0,0 in user coordinates).
fn default_draw_start(&self, i: usize) -> glam::DVec3 {
let tab = &self.tabs[i];
let endpoint = |entity: &acadrust::EntityType| {
let last_grip = match entity {
acadrust::EntityType::Line(line) => {
return Some(glam::DVec3::new(
line.end.x,
line.end.y,
line.end.z,
));
}
acadrust::EntityType::Arc(_) => Some(2),
acadrust::EntityType::LwPolyline(polyline) => {
polyline.vertices.len().checked_sub(1)
}
acadrust::EntityType::Polyline(polyline) => {
polyline.vertices.len().checked_sub(1)
}
acadrust::EntityType::Polyline2D(polyline) => {
polyline.vertices.len().checked_sub(1)
}
acadrust::EntityType::Polyline3D(polyline) => {
polyline.vertices.len().checked_sub(1)
}
_ => None,
}?;
crate::scene::view::dispatch::grips(entity)
.into_iter()
.find(|grip| grip.id == last_grip)
.map(|grip| grip.world)
};
if let Some(handle) = tab.last_draw_anchor {
if tab.scene.entity_belongs_to_active_space(handle) {
if let Some(point) = tab.scene.document.get_entity(handle).and_then(endpoint) {
return point;
}
}
}
let recovered = tab
.scene
.document
.entities()
.filter_map(|entity| {
let handle = entity.common().handle;
tab.scene
.entity_belongs_to_active_space(handle)
.then(|| endpoint(entity).map(|point| (handle, point)))
.flatten()
})
.max_by_key(|(handle, _)| handle.value())
.map(|(_, point)| point);
recovered.unwrap_or_else(|| tab.ucs_origin_world())
}
/// Drop cursor-relative state that was computed in the drawing space being
/// left. This is also used by MVIEW, whose command object survives its
/// intentional paper/model round-trip while its old-space overlays cannot.
pub(super) fn reset_space_interaction_state(&mut self) {
let i = self.active_tab;
self.tabs[i].scene.clear_preview_wire();
self.tabs[i].snap_result = None;
self.last_point = None;
// Points collected in the space being left are meaningless in the new
// one (PR1).
self.clear_accepted_snaps();
self.pending_click_snap = None;
self.snapper.from_point = None;
self.snapper.clear_tracking();
self.otrack_active = None;
self.otrack_kind = None;
self.axis_lock_dir = None;
self.dyn_user_reshaped = false;
self.dyn_coord_absolute = false;
self.grip_hover = None;
self.grip_popup = None;
self.grip_pending = None;
self.visibility_popup = None;
self.hover_dwell = None;
self.ucs_grip_drag = None;
self.ucs_icon_selected = false;
self.ucs_icon_hover = false;
self.tabs[i].pan_mode = false;
self.tabs[i].orbit_mode = false;
self.tabs[i].zoom_dynamic_mode = false;
let _ = self.on_viewport_exit();
}
/// Roll a hot grip back to its pre-drag image and remove every grip-owned
/// overlay. Shared by Escape and drawing-space transitions.
pub(super) fn capture_grip_history_originals(&mut self, i: usize, handles: &[Handle]) {
if !self.grip_history_originals.is_empty() {
return;
}
self.grip_history_originals = handles
.iter()
.filter_map(|&handle| {
let objects = self.tabs[i].scene.solid_history_objects(handle);
(!objects.is_empty()).then_some((handle, objects))
})
.collect();
}
pub(super) fn cancel_active_grip_edit(&mut self) -> bool {
let i = self.active_tab;
let had_grip = self.tabs[i].active_grip.take().is_some()
|| self.grip_add_provisional.is_some()
|| !self.grip_preview_handles.is_empty()
|| !self.grip_history_originals.is_empty();
if !had_grip {
return false;
}
// An Add-Leader arrow being placed is still provisional.
if let Some((handle, grip_id)) = self.grip_add_provisional.take() {
use crate::entities::traits::EntityTypeOps;
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) {
entity.apply_grip_menu(
grip_id,
crate::scene::model::object::GripMenuAction::RemoveLeader,
);
}
self.tabs[i]
.scene
.bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]);
}
let handles = std::mem::take(&mut self.grip_preview_handles);
let originals = std::mem::take(&mut self.grip_originals);
let history_originals = std::mem::take(&mut self.grip_history_originals);
let history_handles: rustc_hash::FxHashSet<_> = history_originals
.iter()
.map(|(handle, _)| *handle)
.collect();
for (_, objects) in history_originals {
for (object_handle, object) in objects {
self.tabs[i]
.scene
.document
.objects
.insert(object_handle, object);
}
}
let mut changed_handles: rustc_hash::FxHashSet<_> = handles.iter().copied().collect();
for (handle, original) in originals {
changed_handles.insert(handle);
if !history_handles.contains(&handle) {
let current = self.tabs[i]
.scene
.document
.get_entity(handle)
.and_then(crate::entities::solid3d::point_of_reference)
.map(|point| [point.x, point.y, point.z]);
let target = crate::entities::solid3d::point_of_reference(&original)
.map(|point| [point.x, point.y, point.z]);
if let (Some(current), Some(target)) = (current, target) {
self.tabs[i].scene.translate_solid_geometry(
handle,
[
target[0] - current[0],
target[1] - current[1],
target[2] - current[2],
],
);
}
}
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) {
*entity = original;
}
}
for handle in history_handles {
let restored = self.tabs[i]
.scene
.document
.solid_history_operation(handle)
.cloned()
.is_some_and(|operation| {
self.tabs[i]
.scene
.rebuild_solid_history(handle, operation)
});
if !restored {
self.tabs[i].scene.reseed_derived_caches(handle);
}
}
for &handle in &handles {
self.tabs[i].scene.preview_hidden.remove(&handle);
}
let changes: Vec<_> = changed_handles
.into_iter()
.map(|handle| (handle, crate::scene::ChangeKind::Modified))
.collect();
self.tabs[i].scene.bump_entities(&changes);
if let Some(dirty_before) = self.grip_dirty_before.take() {
self.tabs[i].dirty = dirty_before;
}
self.grip_reference_wires.clear();
self.grip_text_verts.clear();
self.grip_text_slide = false;
self.tabs[i].scene.clear_preview_wire();
self.tabs[i].snap_result = None;
self.refresh_selected_grips();
self.refresh_properties();
true
}
/// End an interactive command before its drawing coordinate context
/// changes. This is a full interaction boundary, not only an `active_cmd`
/// check: grip drags, suspended editor commands, snaps, tracking, dynamic
/// input and pointer gestures all carry coordinates from the old space.
pub(super) fn cancel_active_command_for_space_change(&mut self) -> Task<Message> {
let i = self.active_tab;
let mut tasks = Vec::new();
let mut cancellation_reported = false;
if let Some(result) = self.tabs[i]
.active_cmd
.as_mut()
.map(|command| command.on_space_change())
{
let (result, message_pending) = match result {
CmdResult::Cancel => (CmdResult::CancelForSpaceChange, false),
other => (other, true),
};
tasks.push(self.apply_cmd_result(result));
// `on_space_change` must be terminal. Force a plain cancellation
// if an external/plugin command violates that contract.
if self.tabs[i].active_cmd.is_some() {
tasks.push(self.apply_cmd_result(CmdResult::CancelForSpaceChange));
} else if message_pending {
self.command_line
.push_info(crate::t!("Command cancelled because the active drawing space changed.").as_ref());
}
cancellation_reported = true;
}
let grip_cancelled = self.cancel_active_grip_edit();
let suspended_cancelled = self.tabs[i].suspended_cmd.take().is_some();
let editor_cancelled = self.text_inline.is_some() || self.mtext_editor.is_some();
if editor_cancelled {
self.text_inline_cancel();
self.mtext_cancel();
}
if !cancellation_reported && (grip_cancelled || suspended_cancelled || editor_cancelled) {
self.command_line
.push_info(crate::t!("Command cancelled because the active drawing space changed.").as_ref());
}
self.command_line.input.clear();
self.command_line.autocomplete_cursor = None;
self.command_line.close_history();
self.reset_space_interaction_state();
if tasks.is_empty() {
Task::none()
} else {
Task::batch(tasks)
}
}
/// Apply LIMCHECK/PLIMCHECK to a point before an interactive command
/// consumes it. LIMITS itself must be able to redefine a rectangle beyond
/// the old boundary, so it is the sole bypass.
pub(super) fn command_point_allowed(&mut self, i: usize, point: glam::DVec3) -> bool {
let checks_limits = self.tabs[i]
.active_cmd
.as_ref()
.is_some_and(|command| command.name() != "LIMITS")
&& self.tabs[i].scene.drawing_limit_check_enabled();
if checks_limits && !self.tabs[i].scene.point_inside_drawing_limits(point) {
self.command_line.push_error(crate::t!("Outside limits.").as_ref());
return false;
}
true
}
/// Refresh screen-space feature picking immediately before point dispatch.
pub(super) fn refresh_command_point_pick_context(&mut self, i: usize) {
if self.tabs[i].active_cmd.as_ref()
.is_some_and(|command| command.wants_point_pick_context())
{
let scene = &self.tabs[i].scene;
let size = scene.selection.borrow().vp_size;
let edit = scene.viewport_edit_frame(size);
let tile = edit.as_ref().map(|(_, rect)| *rect)
.unwrap_or_else(|| scene.active_model_tile_bounds(size.0, size.1));
let bounds = iced::Rectangle { x: 0.0, y: 0.0, width: tile.width, height: tile.height };
let context = if bounds.width > 0.0 && bounds.height > 0.0 {
let (view, eye) = if let Some((camera, _)) = edit {
(camera.view_proj_rte(bounds), camera.eye())
} else {
let camera = scene.camera.borrow();
(camera.view_proj_rte(bounds), camera.eye())
};
Some(crate::command::PointPickContext { view, eye, bounds,
aperture_px: crate::ui::overlay::pick_box_aperture_px(self.pick_box) })
} else { None };
if let Some(command) = self.tabs[i].active_cmd.as_mut() {
command.set_point_pick_context(context);
}
}
}
/// 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 default_start = matches!(&input, StepInput::Enter)
&& self.tabs[i]
.active_cmd
.as_ref()
.is_some_and(|command| command.enter_accepts_default_start());
let input = if default_start {
StepInput::Point(self.default_draw_start(i))
} else {
input
};
if let StepInput::Point(point) = &input {
if !self.command_point_allowed(i, *point) {
return Task::none();
}
// Typed / dynamic-input / headless points carry no snap, but the
// accepted-snap list must stay index-parallel with the points the
// command collects (PR1). Interactive picks record themselves in
// the click handler and never reach here.
self.record_accepted_snap(i, None, None, *point);
}
if default_start {
let StepInput::Point(point) = &input else {
unreachable!("default command start must be a point");
};
self.last_point = Some(*point);
self.dyn_user_reshaped = false;
self.dyn_coord_absolute = false;
self.sync_dyn_fields();
self.reset_tracking_after_point();
self.push_ucs_to_cmd(i);
}
if let StepInput::EntityPick(handle, point) = &input {
let solid_pick = matches!(
self.tabs[i].scene.document.get_entity(*handle),
Some(
acadrust::EntityType::Solid3D(_)
| acadrust::EntityType::Surface(_)
| acadrust::EntityType::Region(_)
| acadrust::EntityType::Body(_)
)
);
let direction = if solid_pick {
self.tabs[i]
.scene
.solid_planar_face_normal_at(*handle, *point)
} else {
self.tabs[i]
.scene
.document
.get_entity(*handle)
.and_then(crate::entities::curve::entity_curve)
.and_then(|curve| curve.plane.normal())
.map(glam::DVec3::from_array)
};
if let Some(command) = self.tabs[i].active_cmd.as_mut() {
command.set_entity_pick_direction(direction);
}
if self.tabs[i].active_cmd.as_ref()
.is_some_and(|command| command.inject_before_entity_pick())
{
if let Some(entity) = self.tabs[i].scene.document.get_entity(*handle).cloned() {
if let Some(command) = self.tabs[i].active_cmd.as_mut() {
command.inject_picked_entity(entity);
}
}
}
}
if let StepInput::SelectionComplete(handles) = &input {
let exclude_locked = self.tabs[i].active_cmd.as_ref()
.is_some_and(|command| command.selection_entities_exclude_locked());
let entities = {
let scene = &self.tabs[i].scene;
handles
.iter()
.filter(|handle| !exclude_locked || !scene.is_layer_locked(**handle))
.filter_map(|handle| {
scene.document.get_entity(*handle).cloned().map(|entity| {
let surface_area = scene
.meshes
.get(handle)
.or_else(|| scene.block_meshes.get(handle))
.map(|mesh| mesh.metrics.surface_area);
SelectionEntity {
handle: *handle,
entity,
surface_area,
}
})
})
.collect()
};
if let Some(command) = self.tabs[i].active_cmd.as_mut() {
command.inject_selection_entities(entities);
}
}
if matches!(&input, StepInput::Point(_)) { self.refresh_command_point_pick_context(i); }
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();
return self.finish_active_command(&toks);
}
if tokens[0].eq_ignore_ascii_case("BACKGROUND")
|| tokens[0].eq_ignore_ascii_case("COLORSCHEME")
{
return self.dispatch_command(cmd);
}
let start_task = 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();
let finish_task = self.finish_active_command(&toks);
Task::batch([start_task, finish_task])
}
/// 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]) -> Task<Message> {
let i = self.active_tab;
if self.tabs[i].active_cmd.is_none() {
return Task::none();
}
self.last_point = None;
let mut tasks = Vec::new();
for tok in &tokens[1..] {
if self.tabs[i].active_cmd.is_none() {
break;
}
tasks.push(self.feed_active_cmd(tok));
}
tasks.push(self.feed_command(StepInput::Enter));
Task::batch(tasks)
}
/// 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();
// Modes that arm the next gesture rather than selecting anything now.
// Dragging normally decides window-vs-crossing from the direction the
// corner travels; asking for one by name has to override that, and hold
// the override until the box is finished. (#596)
if let "W" | "WINDOW" | "C" | "CROSSING" = kw.as_str() {
let crossing = matches!(kw.as_str(), "C" | "CROSSING");
{
let mut selection = self.tabs[i].scene.selection.borrow_mut();
selection.box_crossing = crossing;
selection.box_crossing_locked = true;
}
let hint = if crossing {
crate::t!("Crossing: specify first corner.")
} else {
crate::t!("Window: specify first corner.")
};
self.command_line.push_info(hint.as_ref());
return Some(Task::none());
}
// Whether a pick adds to the set or takes away from it, for the rest of
// this selection. Mirrors PICKADD, which the pick paths already read.
if let "R" | "REMOVE" | "A" | "ADD" = kw.as_str() {
let adding = matches!(kw.as_str(), "A" | "ADD");
self.select_remove_mode = !adding;
let hint = if adding {
crate::t!("Add mode: picks join the selection.")
} else {
crate::t!("Remove mode: picks leave the selection.")
};
self.command_line.push_info(hint.as_ref());
return Some(Task::none());
}
let add: Vec<Handle> = match kw.as_str() {
// Every selectable object of the current space.
"ALL" => self.tabs[i]
.scene
.entity_wires()
.iter()
.filter_map(|w| crate::scene::Scene::handle_from_wire_name(&w.name))
.collect(),
"P" | "PREVIOUS" => self.tabs[i]
.prev_selection
.iter()
.copied()
.filter(|&h| self.tabs[i].scene.document.get_entity(h).is_some())
.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))
.max_by_key(|h| h.value())
.into_iter()
.collect(),
_ => return None,
};
if add.is_empty() {
self.command_line.push_info(crate::t!(match kw.as_str() {
"P" | "PREVIOUS" => "No previous selection set.",
"ALL" => "Nothing to select.",
_ => "No last object.",
}).as_ref());
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(crate::tf!("{count} object(s) added to selection.").as_ref());
self.refresh_properties();
let handles: Vec<Handle> = self.tabs[i]
.scene
.selected_entities()
.into_iter()
.map(|(h, _)| h)
.collect();
Some(self.feed_command(StepInput::SelectionComplete(handles)))
}
pub(super) fn feed_active_cmd(&mut self, token: &str) -> Task<Message> {
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 {
return self.apply_cmd_result(r);
}
let accepts_points = self.tabs[i]
.active_cmd
.as_ref()
.is_some_and(|command| command.entity_pick_accepts_points());
if accepts_points {
if let Some((coord, kind)) = super::helpers::parse_coord(token) {
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(value) => super::helpers::ucs_rotate_vec(coord, value),
None => coord,
}
}
_ => match &ucs {
Some(value) => super::helpers::ucs_to_wcs(coord, value),
None => coord,
},
};
if self.command_point_allowed(i, wcs) {
self.last_point = Some(wcs);
self.push_ucs_to_cmd(i);
return self.feed_command(StepInput::Point(wcs));
}
return Task::none();
}
}
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);
return self.feed_command(StepInput::EntityPick(handle, pt.as_dvec3()));
}
return Task::none();
}
let is_mtp = token.trim_start_matches('_').eq_ignore_ascii_case("MTP")
|| token.trim_start_matches('_').eq_ignore_ascii_case("M2P");
if is_mtp {
let is_point_step = !self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.input_kind().wants_text())
.unwrap_or(true)
|| self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.point_step_accepts_keywords())
.unwrap_or(false);
let not_entity_pick = !self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.needs_entity_pick())
.unwrap_or(false);
if is_point_step && not_entity_pick {
self.start_mtp_modifier(i);
return Task::none();
}
}
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,
},
};
if !self.command_point_allowed(i, wcs) {
return Task::none();
}
self.last_point = Some(wcs);
self.push_ucs_to_cmd(i);
return self.feed_command(StepInput::Point(wcs));
} else {
return 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, mut result: CmdResult) -> Task<Message> {
let i = self.active_tab;
if let CmdResult::ReturnPoint(pt) = result {
self.tabs[i].scene.clear_preview_wire();
if let Some(mut suspended) = self.tabs[i].suspended_cmd.take() {
self.last_point = Some(pt);
let next_res = suspended.on_point(pt);
self.tabs[i].active_cmd = Some(suspended);
result = next_res;
} else {
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.restore_pre_cmd_tangent();
return Task::none();
}
}
let settings = self.tabs[self.active_tab]
.active_cmd
.as_ref()
.and_then(|command| command.sketch_settings());
if let Some((sketch_type, increment, tolerance)) = settings {
let tab = &mut self.tabs[self.active_tab];
let changed = tab.scene.document.header.sketch_type != sketch_type
|| (tab.scene.document.header.sketch_increment - increment).abs() > f64::EPSILON
|| (tab.scene.document.header.sketch_tolerance - tolerance).abs()
> f64::EPSILON;
if changed {
crate::io::set_sketch_settings(
&mut tab.scene.document,
sketch_type,
increment,
tolerance,
);
tab.dirty = true;
}
}
let mline_settings = self.tabs[self.active_tab]
.active_cmd
.as_ref()
.and_then(|command| command.mline_settings());
if let Some((scale, justification, style_name, style_handle)) = mline_settings {
let tab = &mut self.tabs[self.active_tab];
let header = &mut tab.scene.document.header;
let style_handle = style_handle.unwrap_or(acadrust::Handle::NULL);
let changed = (header.multiline_scale - scale).abs() > f64::EPSILON
|| header.multiline_justification != justification
|| !header.multiline_style.eq_ignore_ascii_case(&style_name)
|| header.current_multiline_style_handle != style_handle;
if changed {
header.multiline_scale = scale;
header.multiline_justification = justification;
header.multiline_style = style_name;
header.current_multiline_style_handle = style_handle;
tab.dirty = true;
}
}
let was_active = self.tabs[self.active_tab].active_cmd.is_some();
let preserve_selection = matches!(
&result,
CmdResult::Relaunch(..)
| CmdResult::Dispatch(..)
| CmdResult::SolidEdgeBlend { .. }
| CmdResult::SolidSubtract { .. }
| CmdResult::SliceEntities { .. }
| CmdResult::SliceSurfaceEntities { .. }
);
let task = self.apply_cmd_result_inner(result);
let i = self.active_tab;
let preview_hidden = self.tabs[i]
.active_cmd
.as_ref()
.map(|command| command.preview_hidden_handles().to_vec())
.unwrap_or_default();
self.tabs[i]
.scene
.set_command_preview_hidden(&preview_hidden);
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.tabs[i].scene.set_hover_highlight(None);
self.command_line.set_step_options(Vec::new());
self.restore_add_selected_defaults();
}
task
}
/// Resolve the cell under `click` on table `handle` (or any table in the drawing
/// if `handle` is `Handle::NULL`), arm the cell indicator, and launch the cell
/// editor unless the cell's content is locked. Shared by the double-click shortcut
/// and the TABLEDIT point pick so both agree on grid coordinates and editor setup.
pub(crate) fn begin_table_cell_edit(
&mut self,
i: usize,
handle: acadrust::Handle,
click: glam::DVec3,
) -> crate::modules::annotate::table_cmd::TableCellEditStart {
use crate::modules::annotate::table_cmd::{table_cell_at, TableCellEditStart};
let target = if handle != acadrust::Handle::NULL {
self.tabs[i]
.scene
.document
.get_entity(handle)
.and_then(|entity| match entity {
acadrust::EntityType::Table(table) => Some((handle, table.clone())),
_ => None,
})
} else {
// Find all tables whose grid contains `click`.
// Iterating document.entities() visits entities in draw order;
// taking the last match selects the table on top if they overlap.
let mut matched = None;
for entity in self.tabs[i].scene.document.entities() {
if let acadrust::EntityType::Table(table) = entity {
let h = table.common.handle;
let style = table.table_style_handle.and_then(|style_handle| {
self.tabs[i]
.scene
.document
.objects
.get(&style_handle)
.and_then(|object| match object {
acadrust::objects::ObjectType::TableStyle(style) => Some(style),
_ => None,
})
});
if table_cell_at(table, style, click).is_some() {
matched = Some((h, table.clone()));
}
}
}
matched
};
let Some((handle, table)) = target else {
return TableCellEditStart::NoCell;
};
let style = table.table_style_handle.and_then(|style_handle| {
self.tabs[i]
.scene
.document
.objects
.get(&style_handle)
.and_then(|object| match object {
acadrust::objects::ObjectType::TableStyle(style) => Some(style),
_ => None,
})
});
let Some(hit) = table_cell_at(&table, style, click) else {
return TableCellEditStart::NoCell;
};
// Arm the Properties cell indicator whether or not the cell turns
// out to be editable (matches the double-click behavior).
let index = hit.index(&table);
self.tabs[i].properties.prop_vertex = index;
self.tabs[i].properties.prop_vertex_indicator_active = true;
crate::entities::table::set_prop_current_cell(index);
self.refresh_properties();
if hit.locked {
return TableCellEditStart::LockedCell;
}
let command = crate::modules::annotate::table_cmd::TableCellEditCommand::new(
handle, &table, hit.row, hit.column,
);
self.command_line
.push_info(&crate::command::CadCommand::prompt(&command));
self.tabs[i].active_cmd = Some(Box::new(command));
TableCellEditStart::Started
}
/// Removes and re-solves the first conflicting constraint in the active scope.
pub(super) fn resolve_one_sketch_conflict(&mut self) {
let i = self.active_tab;
let scope = self.tabs[i].current_sketch_scope();
let Some(set) = self.tabs[i].scene.sketch_constraint_set(scope) else { return };
let Some(&(id, _kind)) = set.conflicts.first() else { return };
let Some(constraint) = set.get(id) else { return };
let touched: Vec<Handle> = constraint.refs.iter().map(|r| r.entity).collect();
let label = "Remove conflicting constraint";
let constraints_before = set.clone();
let pending = self.begin_undo(i, label, touched.len(), true);
self.tabs[i]
.scene
.record_undo_sketch_constraints_before(scope, constraints_before);
self.tabs[i].scene.sketch_constraint_set_mut(scope).remove(id);
let changes: Vec<(Handle, crate::scene::ChangeKind)> =
touched.into_iter().map(|h| (h, crate::scene::ChangeKind::Modified)).collect();
self.tabs[i].scene.bump_entities(&changes);
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
}
/// Rebuilds the active tab's `Scene::named_parameters` from the
/// editor's working buffer and re-solves dependent constraints.
pub(super) fn apply_named_parameter_editor_rows(&mut self) {
let i = self.active_tab;
// A name shared by more than one row can't be resolved by picking
// one arbitrarily (`ParameterTable::set` would just let the last
// one silently win) -- refuse every row sharing that name up front,
// same as `named_parameters::preview`'s live check.
let duplicates = crate::ui::window::named_parameters::duplicate_name_rows(&self.named_parameter_editor_rows);
let mut table = crate::scene::named_parameters::ParameterTable::new();
let mut failed = 0usize;
for (idx, row) in self.named_parameter_editor_rows.iter().enumerate() {
let name = row.name.trim();
if name.is_empty() || duplicates.contains(&idx) {
continue;
}
if let Err(e) = table.set(name, row.formula.trim()) {
self.command_line.push_error(crate::tf!("Parameter '{}': {}", name, e).as_ref());
failed += 1;
}
}
if !duplicates.is_empty() {
self.command_line.push_error(crate::tf!("{} row(s) skipped: duplicate parameter name.", duplicates.len()).as_ref());
failed += duplicates.len();
}
let param_count = table.len();
// The whole table just changed, not one isolated value, so there is
// no cheaper "which handles actually moved" test worth doing here —
// re-solve every scope with a constraint driven by *any* named
// parameter (mirrors `solve_scope`'s own "full rebuild, not
// incremental" philosophy).
let touched: Vec<Handle> = self.tabs[i]
.scene
.sketch_constraints
.iter()
.flat_map(|set| set.constraints.iter())
.filter(|c| c.enabled && matches!(c.driving_param, Some(crate::scene::named_parameters::DrivingValue::Named(_))))
.flat_map(|c| c.refs.iter().map(|r| r.entity))
.collect();
let pending = self.begin_undo(i, "Apply named parameters", touched.len(), true);
self.tabs[i].scene.record_undo_named_parameters_before();
self.tabs[i].scene.named_parameters = table;
self.tabs[i].dirty = true;
if !touched.is_empty() {
let changes: Vec<(Handle, crate::scene::ChangeKind)> =
touched.into_iter().map(|h| (h, crate::scene::ChangeKind::Modified)).collect();
self.tabs[i].scene.bump_entities(&changes);
}
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
if failed == 0 {
self.command_line.push_info(crate::tf!("{} parameter(s) applied.", param_count).as_ref());
}
}
/// Re-solves every entity touched by an enabled constraint driven by
/// `name` — the targeted, single-row counterpart to
/// `apply_named_parameter_editor_rows`'s "re-solve what it affects": an
/// inline Properties-panel edit changes one parameter, not the whole
/// table, so unlike that whole-table Apply this can cheaply scope the
/// re-solve to just the entities that actually reference it.
fn resolve_named_parameter_edit(&mut self, i: usize, name: &str) {
let touched: Vec<Handle> = self.tabs[i]
.scene
.sketch_constraints
.iter()
.flat_map(|set| set.constraints.iter())
.filter(|c| {
c.enabled
&& matches!(&c.driving_param, Some(crate::scene::named_parameters::DrivingValue::Named(n)) if n == name)
})
.flat_map(|c| c.refs.iter().map(|r| r.entity))
.collect();
self.tabs[i].dirty = true;
if touched.is_empty() {
return;
}
let changes: Vec<(Handle, crate::scene::ChangeKind)> =
touched.into_iter().map(|h| (h, crate::scene::ChangeKind::Modified)).collect();
self.tabs[i].scene.bump_entities(&changes);
}
/// Commits one field of one Parameters-section row (Properties panel) —
/// the per-row, commit-on-submit counterpart to
/// `apply_named_parameter_editor_rows`'s whole-table Apply. A Formula
/// edit is a plain redefine (`ParameterTable::set` on the existing
/// name); a Name edit is a remove-then-set (there's no rename primitive
/// — `ParameterTable` upserts by name), restoring the old name if the
/// new one fails to validate so nothing is silently lost. Either way
/// this only re-solves what actually depends on this one parameter —
/// no "duplicate name" scan across the whole table is needed the way
/// the buffered modal Apply needs one, since every row here is always
/// already a real, distinct table entry.
pub(super) fn on_prop_param_commit(
&mut self,
index: usize,
field: crate::ui::window::named_parameters::ParamField,
) -> Task<Message> {
use crate::ui::properties::FieldKey;
use crate::ui::window::named_parameters::ParamField;
let i = self.active_tab;
let key = FieldKey::Param(index, field);
self.tabs[i].properties.active_field = None;
let Some(typed) = self.tabs[i].properties.edit_buf.remove(&key) else {
return Task::none();
};
let typed = typed.trim().to_string();
let Some(current) = self.tabs[i].scene.named_parameters().iter().nth(index).cloned() else {
self.refresh_properties();
return Task::none();
};
match field {
ParamField::Formula if typed.is_empty() || typed == current.source => {
self.refresh_properties();
return Task::none();
}
ParamField::Name if typed.is_empty() || typed == current.name => {
self.refresh_properties();
return Task::none();
}
ParamField::Name if self.tabs[i].scene.named_parameters().contains(&typed) => {
self.command_line.push_error(crate::tf!("Parameter '{}' already exists.", typed).as_ref());
self.refresh_properties();
return Task::none();
}
_ => {}
}
let pending = self.begin_undo(i, "Edit named parameter", 0, true);
self.tabs[i].scene.record_undo_named_parameters_before();
let (result, resolve_name) = match field {
ParamField::Formula =>
(self.tabs[i].scene.named_parameters_mut().set(&current.name, &typed), current.name.clone()),
ParamField::Name => {
self.tabs[i].scene.named_parameters_mut().remove(&current.name);
let outcome = self.tabs[i].scene.named_parameters_mut().set(&typed, &current.source);
if outcome.is_err() {
let _ = self.tabs[i].scene.named_parameters_mut().set(&current.name, &current.source);
}
(outcome, typed.clone())
}
};
if let Err(e) = result {
self.tabs[i].scene.take_undo_recording();
self.command_line.push_error(crate::tf!("Parameter '{}': {}", current.name, e).as_ref());
self.refresh_properties();
return Task::none();
}
if field == ParamField::Name {
let affected: Vec<usize> = self.tabs[i]
.scene
.sketch_constraints
.iter()
.enumerate()
.filter_map(|(index, set)| {
set.constraints
.iter()
.any(|constraint| {
matches!(&constraint.driving_param, Some(crate::scene::named_parameters::DrivingValue::Named(name)) if name == &current.name)
})
.then_some(index)
})
.collect();
for set_index in affected {
let scope = self.tabs[i].scene.sketch_constraints[set_index].scope;
let before = self.tabs[i].scene.sketch_constraints[set_index].clone();
self.tabs[i].scene.record_undo_sketch_constraints_before(scope, before);
for constraint in &mut self.tabs[i].scene.sketch_constraints[set_index].constraints {
if let Some(crate::scene::named_parameters::DrivingValue::Named(name)) = &mut constraint.driving_param {
if name == &current.name {
*name = typed.clone();
}
}
}
}
}
self.resolve_named_parameter_edit(i, &resolve_name);
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
self.refresh_properties();
Task::none()
}
/// Removes Parameters-section row `index` immediately. Any
/// constraint that referenced it gets the same "undefined reference"
/// resolve-failure treatment a formula's own bad reference already gets
/// (`build_constraint`) — re-solving surfaces that rather than needing
/// special-case handling here.
pub(super) fn on_prop_param_delete(&mut self, index: usize) -> Task<Message> {
let i = self.active_tab;
let Some(name) = self.tabs[i].scene.named_parameters().iter().nth(index).map(|p| p.name.clone()) else {
return Task::none();
};
let pending = self.begin_undo(i, "Delete named parameter", 0, true);
self.tabs[i].scene.record_undo_named_parameters_before();
self.tabs[i].scene.named_parameters_mut().remove(&name);
self.resolve_named_parameter_edit(i, &name);
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
self.refresh_properties();
Task::none()
}
/// The Parameters section's "+ Add parameter" row: appends a fresh,
/// uniquely-named parameter (`param1`, `param2`, …) the user then
/// renames/redefines inline via `on_prop_param_commit`. Defaults to `1`,
/// not `0`: renaming this onto a name an existing Distance/Radius
/// constraint already references (unusual, but reachable — define
/// first, wire up the constraint's reference later) would otherwise
/// briefly collapse that geometry to a zero-length/zero-radius
/// degenerate state, which is a genuine numerical singularity for a
/// point-to-point distance constraint to grow back out of (no defined
/// direction to separate two exactly-coincident points from). `1` never
/// hits that.
pub(super) fn on_prop_param_add_new(&mut self) -> Task<Message> {
let i = self.active_tab;
let mut n = 1usize;
let name = loop {
let candidate = format!("param{n}");
if !self.tabs[i].scene.named_parameters().contains(&candidate) {
break candidate;
}
n += 1;
};
let pending = self.begin_undo(i, "Add named parameter", 0, true);
self.tabs[i].scene.record_undo_named_parameters_before();
let _ = self.tabs[i].scene.named_parameters_mut().set(&name, "1");
self.tabs[i].dirty = true;
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
self.refresh_properties();
Task::none()
}
pub(in crate::app) fn start_mtp_modifier(&mut self, i: usize) {
let parent = self.tabs[i].active_cmd.take();
self.tabs[i].suspended_cmd = parent;
self.tabs[i].scene.clear_preview_wire();
let cmd = crate::command::Mid2PointCommand::new();
let prompt = crate::command::CadCommand::prompt(&cmd);
self.tabs[i].active_cmd = Some(Box::new(cmd));
self.command_line.push_info(&prompt);
self.command_line.set_step_options(Vec::new());
self.refresh_active_cmd_preview(i);
}
/// Apply a command replacement without deleting a one-to-one entity edit.
/// Splits and entity-type conversions still allocate replacement identities.
fn replace_command_entity(
&mut self,
tab: usize,
handle: Handle,
mut entities: Vec<acadrust::EntityType>,
) -> Vec<Handle> {
let same_type = entities.len() == 1 && self.tabs[tab].scene.document.get_entity(handle)
.is_some_and(|original| std::mem::discriminant(original) == std::mem::discriminant(&entities[0]));
let handles = if same_type {
let mut entity = entities.pop().expect("one replacement");
entity.common_mut().handle = handle;
// Scene::update_entity also retains the live owning block and
// refreshes dependent render caches without erase notifications.
if self.tabs[tab].scene.update_entity(entity) { vec![handle] } else { Vec::new() }
} else {
let owner = self.tabs[tab].scene.document.get_entity(handle).map(|entity| entity.common().owner_handle);
self.tabs[tab].scene.erase_entities(&[handle]);
entities.into_iter().map(|mut entity| {
entity.common_mut().handle = Handle::NULL;
if let Some(owner) = owner { entity.common_mut().owner_handle = owner; }
self.tabs[tab].scene.add_entity(entity)
}).collect()
};
for &updated in &handles {
if matches!(self.tabs[tab].scene.document.get_entity(updated), Some(acadrust::EntityType::Dimension(_))) {
self.tabs[tab].scene.invalidate_dim_block_recorded(updated);
}
}
handles
}
fn apply_pedit_result(
&mut self,
tab: usize,
handle: Handle,
op: crate::modules::draw::modify::pedit::PeditOp,
) -> Task<Message> {
use crate::modules::draw::modify::pedit::{apply_pedit, convert_to_polyline, PeditOp};
if !matches!(
&op,
PeditOp::Multiple(_, _) | PeditOp::JoinSelection(_, _, _)
)
&& self.reject_locked_edit(tab, handle)
{
return Task::none();
}
match &op {
PeditOp::JoinSelection(handles, fuzz, kind) => {
let mut available = handles
.iter()
.filter_map(|handle| {
if self.tabs[tab].scene.is_layer_locked(*handle) {
return None;
}
self.tabs[tab]
.scene
.document
.get_entity(*handle)
.cloned()
.map(|entity| (*handle, entity))
})
.collect::<Vec<_>>();
let connector_distance = crate::modules::draw::modify::pedit::selection_connector_distance(&available, *fuzz);
let mut changes = Vec::new();
while !available.is_empty() {
let (source_handle, source) = available.remove(0);
let candidates = available
.iter()
.map(|(handle, entity)| (*handle, entity))
.collect::<Vec<_>>();
if let Some((mut result, consumed)) =
crate::modules::draw::modify::pedit::join_selection_extend(
&source,
&candidates,
*fuzz,
*kind,
connector_distance,
)
{
*result.common_mut() = source.common().clone();
available.retain(|(handle, _)| !consumed.contains(handle));
changes.push((source_handle, result, consumed));
}
}
if !changes.is_empty() {
self.push_undo_snapshot(tab, "PEDIT");
for (source, replacement, consumed) in changes {
self.tabs[tab].scene.erase_entities(&consumed);
self.tabs[tab].scene.update_entity(replacement.clone());
if let Some(command) = self.tabs[tab].active_cmd.as_mut() {
for old in consumed {
command.on_entity_replaced(old, &[source]);
}
command.inject_picked_entity(replacement);
}
}
if let Some(command) = self.tabs[tab].active_cmd.as_mut() {
command.on_pedit_applied();
}
self.tabs[tab].dirty = true;
self.refresh_properties();
}
}
PeditOp::Multiple(handles, operation) => {
let replacements: Vec<_> = handles
.iter()
.filter(|handle| !self.tabs[tab].scene.is_layer_locked(**handle))
.filter_map(|handle| {
let original = self.tabs[tab].scene.document.get_entity(*handle)?;
if matches!(operation.as_ref(), PeditOp::ConvertToPolyline) {
convert_to_polyline(original)
.map(|replacement| (*handle, replacement, true))
} else {
let mut replacement = original.clone();
apply_pedit(&mut replacement, operation)
.then_some((*handle, replacement, false))
}
})
.collect();
if replacements.is_empty() {
return Task::none();
}
self.push_undo_snapshot(tab, "PEDIT");
for (handle, replacement, converted) in replacements {
let updated_handle = if converted {
self.tabs[tab].scene.erase_entities(&[handle]);
let new_handle = self.tabs[tab].scene.add_entity(replacement);
if let Some(command) = self.tabs[tab].active_cmd.as_mut() {
command.on_entity_replaced(handle, &[new_handle]);
}
new_handle
} else {
if let Some(entity) = self.tabs[tab].scene.document.get_entity_mut(handle) {
*entity = replacement;
}
self.tabs[tab].scene.refresh_fill_model(handle);
self.tabs[tab]
.scene
.bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]);
handle
};
let updated = self.tabs[tab]
.scene
.document
.get_entity(updated_handle)
.cloned();
if let (Some(command), Some(entity)) =
(self.tabs[tab].active_cmd.as_mut(), updated)
{
command.inject_picked_entity(entity);
}
}
if let Some(command) = self.tabs[tab].active_cmd.as_mut() {
command.on_pedit_applied();
}
self.tabs[tab].dirty = true;
self.refresh_properties();
}
PeditOp::VertexRange { first, last, split } => {
let pieces = self.tabs[tab]
.scene
.document
.get_entity(handle)
.and_then(|entity| {
crate::modules::draw::modify::pedit::edit_vertex_range(
entity, *first, *last, *split,
)
});
if let Some(mut pieces) = pieces {
self.push_undo_snapshot(tab, "PEDIT");
let source = pieces.remove(0);
self.tabs[tab].scene.update_entity(source);
for mut piece in pieces {
piece.common_mut().handle = Handle::NULL;
self.tabs[tab].scene.add_entity(piece);
}
let updated = self.tabs[tab].scene.document.get_entity(handle).cloned();
if let Some(command) = self.tabs[tab].active_cmd.as_mut() {
if let Some(entity) = updated {
command.inject_picked_entity(entity);
}
command.on_pedit_applied();
}
self.tabs[tab].dirty = true;
self.refresh_properties();
} else {
self.command_line.push_error("PEDIT: invalid vertex range.");
}
}
PeditOp::ConvertToPolyline => {
let converted = self.tabs[tab]
.scene
.document
.get_entity(handle)
.and_then(convert_to_polyline);
match converted {
Some(polyline) => {
return self.apply_cmd_result(CmdResult::ReplaceEntity(
handle,
vec![polyline],
));
}
None => self.command_line.push_error(
crate::t!("PEDIT: cannot convert this entity.").as_ref(),
),
}
}
_ => {
self.push_undo_snapshot(tab, "PEDIT");
let changed = self.tabs[tab]
.scene
.document
.get_entity_mut(handle)
.map(|entity| apply_pedit(entity, &op))
.unwrap_or(false);
if changed {
let updated = self.tabs[tab].scene.document.get_entity(handle).cloned();
if let Some(command) = self.tabs[tab].active_cmd.as_mut() {
if let Some(entity) = updated {
command.inject_picked_entity(entity);
}
command.on_pedit_applied();
}
self.tabs[tab].dirty = true;
self.tabs[tab].scene.refresh_fill_model(handle);
self.tabs[tab]
.scene
.bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]);
self.command_line
.push_output(crate::t!("PEDIT: applied.").as_ref());
self.refresh_properties();
} else {
self.discard_last_undo_entry(tab);
self.command_line.push_error(
crate::t!("PEDIT: operation not applicable to this entity.").as_ref(),
);
}
}
}
if let Some(prompt) = self.tabs[tab].active_cmd.as_ref().map(|command| command.prompt()) {
self.command_line.push_info(&prompt);
}
Task::none()
}
fn apply_cmd_result_inner(&mut self, result: CmdResult) -> Task<Message> {
let i = self.active_tab;
if let Some(bind) = self.tabs[i].active_cmd.as_ref().and_then(|command| command.nested_copy_bind_setting()) {
if self.ncopy_bind != bind {
self.ncopy_bind = bind;
self.persist_settings_if_changed();
}
}
let preserve_commit_style = self.tabs[i].active_cmd.as_ref()
.is_some_and(|command| command.preserve_commit_style());
let preserve_commit_layer = self.tabs[i]
.active_cmd
.as_ref()
.is_some_and(|command| command.preserve_commit_layer());
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.command_line.set_step_options(Vec::new());
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);
}
let opts = self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.options())
.unwrap_or_default();
self.command_line.set_step_options(opts);
if !self.tabs[i]
.active_cmd
.as_ref()
.is_some_and(|c| c.entity_pick_highlights_hover())
{
self.tabs[i].scene.set_hover_highlight(None);
}
// 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();
self.refresh_area_preview(i);
}
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) => {
let source_handle = entity.common().handle;
if !source_handle.is_null()
&& self.tabs[i]
.scene
.document
.get_entity(source_handle)
.is_some()
&& self.reject_locked_edit(i, source_handle)
{
return Task::none();
}
// 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(_)
| acadrust::EntityType::LwPolyline(_)
| acadrust::EntityType::Polyline2D(_)
) {
self.update_cont_anchor(&entity);
}
let label = self.history_label_from_active_cmd(i, "ENTITY");
// Ordinary drawables, viewports and raster images use targeted
// entity/object deltas. Block sentinels and novel layers retain
// the structure snapshot fallback.
let delta_safe = self.delta_add_safe(i, &entity);
let pending = self.begin_undo(i, label, 1, delta_safe);
let is_associative_dimension = matches!(
entity,
acadrust::EntityType::Dimension(
acadrust::entities::Dimension::Linear(_)
| acadrust::entities::Dimension::Aligned(_)
)
);
let association_enabled =
self.tabs[i].scene.document.header.dimension_associativity == 2;
let committed = if preserve_commit_layer {
self.commit_entity_handle_preserve_layer(entity)
} else {
self.commit_entity_handle(entity)
};
if is_associative_dimension && association_enabled {
if let Some(handle) = committed {
let sources = self.infer_dimension_sources_guarded(i, handle);
self.tabs[i]
.scene
.attach_dimension_association(handle, sources);
}
}
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::CommitEntities(mut entities) => {
let locked_source = entities.iter().find_map(|entity| {
let handle = entity.common().handle;
(!handle.is_null()
&& self.tabs[i].scene.document.get_entity(handle).is_some()
&& self.tabs[i].scene.is_layer_locked(handle))
.then_some(handle)
});
if let Some(handle) = locked_source {
self.reject_locked_edit(i, handle);
return Task::none();
}
let label = self.history_label_from_active_cmd(i, "ENTITY");
let symbol_names = self.tabs[i].active_cmd.as_ref()
.and_then(|command| command.nested_copy_symbol_names()).cloned();
let delta_safe = symbol_names.is_none() && entities
.iter()
.all(|entity| self.delta_add_safe(i, entity));
let pending = self.begin_undo(i, label, entities.len(), delta_safe);
if let Some(names) = symbol_names {
let retained = self.tabs[i].scene.document.localize_nested_copy_symbols(&mut entities, &names);
if retained > 0 {
self.command_line.push_info(&format!("{} copied object(s) retain imported style references whose dependencies cannot be localized.", retained));
}
self.refresh_layer_panel();
}
for entity in entities {
if preserve_commit_style {
let _ = self.commit_entity_handle_preserve_style(entity);
} else if preserve_commit_layer {
let _ = self.commit_entity_handle_preserve_layer(entity);
} else {
self.commit_entity(entity);
}
}
self.tabs[i].dirty = true;
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);
}
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
}
CmdResult::CommitEntitiesAndExit(mut entities) => {
let label = self.history_label_from_active_cmd(i, "ENTITY");
let symbol_names = self.tabs[i].active_cmd.as_ref()
.and_then(|command| command.nested_copy_symbol_names()).cloned();
let delta_safe = symbol_names.is_none() && entities
.iter()
.all(|entity| self.delta_add_safe(i, entity));
let pending = self.begin_undo(i, label, entities.len(), delta_safe);
if let Some(names) = symbol_names {
let retained = self.tabs[i].scene.document.localize_nested_copy_symbols(&mut entities, &names);
if retained > 0 {
self.command_line.push_info(&format!("{} copied object(s) retain imported style references whose dependencies cannot be localized.", retained));
}
self.refresh_layer_panel();
}
for entity in entities {
if preserve_commit_style {
let _ = self.commit_entity_handle_preserve_style(entity);
} else if preserve_commit_layer {
let _ = self.commit_entity_handle_preserve_layer(entity);
} else {
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::MviewCreate {
viewport,
preserve_view,
} => {
let saved_view = preserve_view.then(|| {
(
viewport.view_target.clone(),
viewport.view_direction.clone(),
viewport.view_center.clone(),
viewport.view_height,
viewport.custom_scale,
viewport.lens_length,
viewport.twist_angle,
viewport.status.perspective,
)
});
let label = self.history_label_from_active_cmd(i, "MVIEW");
let pending = self.begin_undo(i, label, 1, true);
let handle = self.commit_entity_handle(
acadrust::EntityType::Viewport(viewport),
);
if let (Some(handle), Some(saved)) = (handle, saved_view) {
if let Some(acadrust::EntityType::Viewport(viewport)) =
self.tabs[i].scene.document.get_entity_mut(handle)
{
viewport.view_target = saved.0;
viewport.view_direction = saved.1;
viewport.view_center = saved.2;
viewport.view_height = saved.3;
viewport.custom_scale = saved.4;
viewport.lens_length = saved.5;
viewport.twist_angle = saved.6;
viewport.status.perspective = saved.7;
}
self.tabs[i].scene.camera_generation += 1;
}
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(pending) = pending {
self.commit_undo_delta(i, pending);
}
}
CmdResult::MviewCreateClipped {
boundary,
boundary_handle,
} => {
if boundary.is_none() {
let scene = &self.tabs[i].scene;
let valid = scene
.entity_belongs_to_current_layout(boundary_handle)
&& scene
.document
.get_entity(boundary_handle)
.is_some_and(|entity| {
match entity {
acadrust::EntityType::Circle(_) => true,
acadrust::EntityType::Ellipse(ellipse) => ellipse.is_full(),
acadrust::EntityType::LwPolyline(polyline) => {
polyline.is_closed
}
acadrust::EntityType::Polyline(polyline) => {
polyline.is_closed()
}
acadrust::EntityType::Polyline2D(polyline) => {
polyline.is_closed()
}
acadrust::EntityType::Polyline3D(polyline) => {
polyline.flags.closed
}
_ => false,
}
});
if !valid {
self.command_line.push_error(
crate::t!("MVIEW Object: select a closed paper-space circle, ellipse, or polyline.").as_ref(),
);
if let Some(prompt) =
self.tabs[i].active_cmd.as_ref().map(|command| command.prompt())
{
self.command_line.push_info(&prompt);
}
return Task::none();
}
}
let created_boundary = boundary.is_some();
let touched = 2;
let label = self.history_label_from_active_cmd(i, "MVIEW");
let pending = self.begin_undo(i, label, touched, true);
let clip_handle = match boundary {
Some(mut boundary) => {
// A non-rectangular viewport owns a helper boundary
// entity through `clip_boundary_handle`. Keep that
// helper in the document for DWG compatibility and
// stencil clipping, but do not expose it as a separate
// selectable polyline.
boundary.common_mut().invisible = true;
match self.commit_entity_handle(boundary) {
Some(handle) => handle,
None => {
self.tabs[i].active_cmd = None;
if let Some(pending) = pending {
self.commit_undo_delta(i, pending);
}
return Task::none();
}
}
}
None => boundary_handle,
};
let polygon = self.tabs[i]
.scene
.clip_boundary_polygon(clip_handle, 0.0);
let bounds: Option<(f64, f64, f64, f64)> =
polygon.iter().fold(None, |bounds, point| {
if !point[0].is_finite() || !point[1].is_finite() {
return bounds;
}
Some(match bounds {
Some((min_x, min_y, max_x, max_y)) => (
min_x.min(point[0] as f64),
min_y.min(point[1] as f64),
max_x.max(point[0] as f64),
max_y.max(point[1] as f64),
),
None => (
point[0] as f64,
point[1] as f64,
point[0] as f64,
point[1] as f64,
),
})
});
let Some((min_x, min_y, max_x, max_y)) = bounds else {
self.command_line
.push_error(crate::t!("MVIEW: the clipping boundary has no usable area.").as_ref());
self.tabs[i].active_cmd = None;
if let Some(pending) = pending {
self.commit_undo_delta(i, pending);
}
return Task::none();
};
if max_x - min_x < 1e-6 || max_y - min_y < 1e-6 {
self.command_line
.push_error(crate::t!("MVIEW: the clipping boundary has no usable area.").as_ref());
self.tabs[i].active_cmd = None;
if let Some(pending) = pending {
self.commit_undo_delta(i, pending);
}
return Task::none();
}
let mut viewport = acadrust::entities::Viewport::new();
viewport.center = acadrust::types::Vector3::new(
(min_x + max_x) / 2.0,
(min_y + max_y) / 2.0,
0.0,
);
viewport.width = max_x - min_x;
viewport.height = max_y - min_y;
viewport.id = 2;
viewport.clip_boundary_handle = clip_handle;
let viewport_handle = self.commit_entity_handle(
acadrust::EntityType::Viewport(viewport),
);
if let Some(viewport_handle) = viewport_handle {
if !created_boundary {
let before = self.tabs[i]
.scene
.document
.get_entity(clip_handle)
.cloned()
.map(std::sync::Arc::new);
self.tabs[i]
.scene
.record_undo_before(clip_handle, before);
}
if let Some(boundary) =
self.tabs[i].scene.document.get_entity_mut(clip_handle)
{
let common = boundary.common_mut();
common.invisible = true;
if !common.reactors.contains(&viewport_handle) {
common.reactors.push(viewport_handle);
}
}
self.tabs[i].scene.bump_entities(&[(
clip_handle,
crate::scene::ChangeKind::Modified,
)]);
}
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(pending) = pending {
self.commit_undo_delta(i, pending);
}
}
CmdResult::WipeoutFromPolyline {
handle,
erase_source,
} => {
let wipeout = {
let scene = &self.tabs[i].scene;
scene
.entity_belongs_to_active_space(handle)
.then(|| scene.document.get_entity(handle))
.flatten()
.and_then(
crate::modules::draw::draw::wipeout::wipeout_from_polyline,
)
};
if let Some(wipeout) = wipeout {
if erase_source {
return self.apply_cmd_result(CmdResult::ReplaceMany(
vec![(handle, Vec::new())],
vec![wipeout],
));
}
return self.apply_cmd_result(CmdResult::CommitAndExit(wipeout));
}
self.command_line.push_error(
crate::t!("WIPEOUT Polyline: select a straight, closed, planar 2D polyline with at least 3 non-intersecting vertices.").as_ref(),
);
let command =
crate::modules::draw::draw::wipeout::WipeoutCommand::new_polyline();
self.command_line.push_info(
&crate::command::CadCommand::prompt(&command),
);
self.tabs[i].active_cmd = Some(Box::new(command));
}
CmdResult::MviewSwitchLayout(layout) => {
let task = self.on_layout_switch_preserving_command(layout);
if let Some(prompt) =
self.tabs[i].active_cmd.as_ref().map(|command| command.prompt())
{
self.command_line.push_info(&prompt);
}
return task;
}
CmdResult::MviewCancelToLayout(layout) => {
self.tabs[i].scene.clear_preview_wire();
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.restore_pre_cmd_tangent();
return self.on_layout_switch(layout);
}
CmdResult::TransformSelected(mut handles, transform) => {
handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle));
if handles.is_empty() {
self.tabs[i].active_cmd = None;
return Task::none();
}
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);
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(mut handles, transform) => {
handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle));
if handles.is_empty() {
return Task::none();
}
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);
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::CopyToClipboard { handles, base } => {
let count = self.copy_entities_to_clipboard(i, &handles, base);
self.command_line.push_info(crate::tf!(
"{} object(s) copied to clipboard.",
count
).as_ref());
let prompt = self.tabs[i]
.active_cmd
.as_ref()
.map(|command| command.prompt());
if let Some(prompt) = prompt {
self.command_line.push_info(&prompt);
}
self.command_line.set_step_options(
self.tabs[i]
.active_cmd
.as_ref()
.map(|command| command.options())
.unwrap_or_default(),
);
if !self.tabs[i]
.active_cmd
.as_ref()
.is_some_and(|command| command.entity_pick_highlights_hover())
{
self.tabs[i].scene.set_hover_highlight(None);
}
self.sync_dyn_fields();
self.refresh_area_preview(i);
}
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();
}
let insert_block_name = match &entity {
acadrust::EntityType::Insert(ins) => Some(ins.block_name.clone()),
_ => None,
};
// 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);
let is_associative_dimension = matches!(
entity,
acadrust::EntityType::Dimension(
acadrust::entities::Dimension::Linear(_)
| acadrust::entities::Dimension::Aligned(_)
)
);
let association_enabled =
self.tabs[i].scene.document.header.dimension_associativity == 2;
let committed = self.commit_entity_handle(entity);
if is_associative_dimension && association_enabled {
if let Some(handle) = committed {
let sources = self.infer_dimension_sources_guarded(i, handle);
self.tabs[i]
.scene
.attach_dimension_association(handle, sources);
}
}
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(name) = insert_block_name {
self.record_block_insert(&name);
}
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
}
CmdResult::CommitDimension {
mut entity,
association,
preserve_base_style,
continue_command,
} => {
let label = self.history_label_from_active_cmd(i, "DIMENSION");
let association_mode = self.tabs[i]
.scene
.document
.header
.dimension_associativity;
let single_source_dimension = matches!(
&entity,
acadrust::EntityType::Dimension(
acadrust::entities::Dimension::Ordinate(_)
)
);
// A dimension placed on the sheet but measuring model geometry
// through a viewport carries the compensation as a negative
// DIMLFAC override.
self.apply_viewport_dimension_measurement(i, &mut entity);
// ...and is deliberately *not* associated in paper space: its
// definition points are projected paper coordinates, so a
// paper-space association would bind it to geometry that is
// not there. PR3 records the real dimension -> viewport ->
// entity chain instead. One gate, shared with
// `infer_dimension_sources_guarded`.
let association_allowed = self.dimension_association_allowed(i);
let inherited_dimension = if preserve_base_style {
match &entity {
acadrust::EntityType::Dimension(dimension) => Some((
dimension.base().common.layer.clone(),
dimension.base().style_name.clone(),
)),
_ => None,
}
} else {
None
};
let pending = if association_mode == 0 {
let layer = self.tabs[i].active_layer.clone();
if layer != "0" || entity.as_entity().layer().is_empty() {
entity.as_entity_mut().set_layer(layer);
}
crate::scene::view::dispatch::apply_color(
&mut entity,
self.ribbon.active_color,
);
crate::scene::view::dispatch::apply_common_prop(
&mut entity,
"linetype",
&self.ribbon.active_linetype.clone(),
);
crate::scene::view::dispatch::apply_line_weight(
&mut entity,
self.ribbon.active_lineweight,
);
let celtscale = self.tabs[i]
.scene
.document
.header
.current_entity_linetype_scale;
if (celtscale - 1.0).abs() > 1e-9 && celtscale.abs() > 1e-9 {
entity.common_mut().linetype_scale = celtscale;
}
crate::scene::creation_style::apply_current_creation_styles(
&self.tabs[i].scene.document,
&mut entity,
);
if let (Some((layer, style_name)), acadrust::EntityType::Dimension(dimension)) =
(inherited_dimension, &mut entity)
{
dimension.base_mut().common.layer = layer;
dimension.base_mut().style_name = style_name;
}
let pieces = crate::modules::draw::modify::explode::explode_entity(
&entity,
&self.tabs[i].scene.document,
);
let delta_safe = pieces
.iter()
.all(|piece| self.delta_add_safe(i, piece));
let pending = self.begin_undo(i, label, pieces.len(), delta_safe);
for piece in pieces {
self.tabs[i].scene.add_entity(piece);
}
pending
} else {
let delta_safe = self.delta_add_safe(i, &entity);
let pending = self.begin_undo(i, label, 1, delta_safe);
if let Some(handle) = self.commit_entity_handle_with_dimension_policy(
entity,
preserve_base_style,
) {
if association_mode == 2 && association_allowed {
let mut changes = vec![
(handle, crate::scene::ChangeKind::Modified),
];
match association {
crate::command::DimensionAssociationInput::Infer(source) => {
let sources: Vec<_> = source.map_or_else(
|| {
self.infer_dimension_sources_guarded(i, handle)
},
|source| {
if single_source_dimension {
vec![Some(source)]
} else {
vec![Some(source), Some(source)]
}
},
);
self.tabs[i]
.scene
.attach_dimension_association(handle, sources.clone());
changes.extend(sources.into_iter().flatten().map(|source| {
(source, crate::scene::ChangeKind::Modified)
}));
}
crate::command::DimensionAssociationInput::Explicit(sources) => {
self.tabs[i]
.scene
.attach_dimension_association_sources(handle, sources.clone());
changes.extend(sources.into_iter().flatten().map(|source| {
(source.handle, crate::scene::ChangeKind::Modified)
}));
}
}
changes.sort_by_key(|(handle, _)| handle.value());
changes.dedup_by_key(|(handle, _)| handle.value());
self.tabs[i].scene.bump_entities(&changes);
} else if association_mode == 2 {
// Measured through a layout viewport: a paper-space
// association would bind projected coordinates to
// geometry that is not on the sheet. Record the real
// chain instead — dimension -> viewport -> block
// path -> entity -> feature — from the AcceptedSnaps
// PR1 captured, in definition-point slot order.
self.attach_viewport_dimension_association(i, handle);
}
}
pending
};
self.tabs[i].dirty = true;
self.tabs[i].scene.clear_preview_wire();
self.tabs[i].snap_result = None;
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
if continue_command {
if let Some(prompt) = self.tabs[i]
.active_cmd
.as_ref()
.map(|cmd| cmd.prompt())
{
self.command_line.push_info(&prompt);
}
} else {
self.tabs[i].active_cmd = None;
self.restore_pre_cmd_tangent();
}
}
CmdResult::CommitDimensionsAndExit(dimensions) => {
let association_mode = self.tabs[i]
.scene
.document
.header
.dimension_associativity;
let mut made = 0;
let pending = if association_mode == 0 {
let mut pieces = Vec::new();
for (mut entity, _) in dimensions {
let layer = self.tabs[i].active_layer.clone();
if layer != "0" || entity.as_entity().layer().is_empty() {
entity.as_entity_mut().set_layer(layer);
}
crate::scene::view::dispatch::apply_color(
&mut entity,
self.ribbon.active_color,
);
crate::scene::view::dispatch::apply_common_prop(
&mut entity,
"linetype",
&self.ribbon.active_linetype.clone(),
);
crate::scene::view::dispatch::apply_line_weight(
&mut entity,
self.ribbon.active_lineweight,
);
let celtscale = self.tabs[i]
.scene
.document
.header
.current_entity_linetype_scale;
if (celtscale - 1.0).abs() > 1e-9 && celtscale.abs() > 1e-9 {
entity.common_mut().linetype_scale = celtscale;
}
crate::scene::creation_style::apply_current_creation_styles(
&self.tabs[i].scene.document,
&mut entity,
);
let exploded = crate::modules::draw::modify::explode::explode_entity(
&entity,
&self.tabs[i].scene.document,
);
if !exploded.is_empty() {
made += 1;
}
pieces.extend(exploded);
}
let delta_safe = pieces
.iter()
.all(|piece| self.delta_add_safe(i, piece));
let pending = self.begin_undo(i, "QDIM", pieces.len(), delta_safe);
for piece in pieces {
self.tabs[i].scene.add_entity(piece);
}
pending
} else {
let delta_safe = dimensions
.iter()
.all(|(entity, _)| self.delta_add_safe(i, entity));
let pending = self.begin_undo(i, "QDIM", dimensions.len(), delta_safe);
for (entity, association) in dimensions {
let Some(handle) = self.commit_entity_handle(entity) else {
continue;
};
made += 1;
if association_mode != 2 {
continue;
}
let sources = match association {
crate::command::DimensionAssociationInput::Infer(source) => {
source.map_or_else(
|| {
self.infer_dimension_sources_guarded(i, handle)
.into_iter()
.map(|source| {
source.map(
crate::command::DimensionAssociationSource::inferred,
)
})
.collect()
},
|source| {
vec![Some(
crate::command::DimensionAssociationSource::inferred(
source,
),
)]
},
)
}
crate::command::DimensionAssociationInput::Explicit(sources) => {
sources
}
};
self.tabs[i]
.scene
.attach_dimension_association_sources(handle, sources.clone());
let mut changes = vec![(handle, crate::scene::ChangeKind::Modified)];
changes.extend(sources.into_iter().flatten().map(|source| {
(source.handle, crate::scene::ChangeKind::Modified)
}));
changes.sort_by_key(|(handle, _)| handle.value());
changes.dedup_by_key(|(handle, _)| handle.value());
self.tabs[i].scene.bump_entities(&changes);
}
pending
};
if made > 0 {
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(crate::tf!("QDIM {made} dimensions created.").as_ref());
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
}
CmdResult::SetQuickDimensionSnapPriority(priority) => {
self.quick_dimension_snap_priority = priority.min(1);
self.persist_settings_if_changed();
let prompt = self.tabs[i].active_cmd.as_ref().map(|command| command.prompt());
if let Some(prompt) = prompt {
self.command_line.push_info(&prompt);
}
}
CmdResult::AlignMLeaders {
mut handles,
from,
to,
} => {
handles.retain(|handle| {
!self.tabs[i].scene.is_layer_locked(*handle)
&& matches!(
self.tabs[i].scene.document.get_entity(*handle),
Some(acadrust::EntityType::MultiLeader(_))
)
});
if !handles.is_empty() {
self.push_undo_snapshot(i, "MLEADERALIGN");
if apply_mleader_align(&mut self.tabs[i].scene, &handles, from, to) {
self.tabs[i].dirty = true;
self.command_line.push_output(
crate::t!("MLEADERALIGN Leaders aligned.").as_ref(),
);
}
}
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::CollectMLeaders { handles, point } => {
let compatible = compatible_mleader_collect_handles(&self.tabs[i].scene, &handles);
if compatible.len() >= 2 {
self.push_undo_snapshot(i, "MLEADERCOLLECT");
if apply_mleader_collect(&mut self.tabs[i].scene, &compatible, point) {
self.tabs[i].dirty = true;
self.command_line.push_output(
crate::t!("MLEADERCOLLECT Leaders collected.").as_ref(),
);
}
}
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::CommitSolid {
entity,
solid,
history,
erase_source,
} => {
let label = self.history_label_from_active_cmd(i, "SOLID");
let erase_source = erase_source.filter(|handle| {
self.delete_objects != 0
&& !self.tabs[i].scene.is_layer_locked(*handle)
});
let pending = self.begin_undo(
i,
label,
1 + usize::from(erase_source.is_some()),
true,
);
let handle = self.add_solid_model(entity, *solid, history);
if !handle.is_null() {
if let Some(source) = erase_source {
self.tabs[i].scene.erase_entities(&[source]);
}
self.tabs[i].dirty = true;
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
}
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 annotative_mleader = matches!(
&entity,
acadrust::EntityType::MultiLeader(ml) if ml.enable_annotation_scale
);
let label = self.history_label_from_active_cmd(i, "ENTITY");
let delta_safe = self.delta_add_safe(i, &entity) && !annotative_mleader;
let pending = self.begin_undo(i, label, 1, delta_safe);
let handle = self.commit_entity_handle(entity);
if annotative_mleader {
let scale_handle = self.tabs[i].scene.creation_annotation_scale_handle();
if let (Some(handle), Some(scale_handle)) = (handle, scale_handle) {
crate::scene::annotative::create_annotation_context(
&mut self.tabs[i].scene.document,
handle,
scale_handle,
);
self.tabs[i]
.scene
.bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]);
}
}
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,
open_editor,
} => {
let label = self.history_label_from_active_cmd(i, "ENTITY");
let delta_safe = entities
.iter()
.all(|entity| self.delta_add_safe(i, entity));
let pending = self.begin_undo(i, label, entities.len(), delta_safe);
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) {
let linked = if let Some(acadrust::EntityType::Leader(l)) =
self.tabs[i].scene.document.get_entity_mut(lh)
{
l.annotation_handle = ah;
true
} else {
false
};
if linked {
// The LEADER may already have received its annotation context while
// annotation_handle was still NULL. Refresh it now that the MTEXT link
// is known so the context represents the finished leader.
self.tabs[i]
.scene
.sync_displayed_annotation_context(lh);
self.tabs[i].scene.bump_entities(&[(
lh,
crate::scene::ChangeKind::Modified,
)]);
}
}
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 open_editor {
if let Some(h) = edit_handle {
return self.begin_text_edit(h);
}
}
}
CmdResult::CreateBlock {
mut handles,
name,
base,
} => {
handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle));
if handles.is_empty() {
self.command_line
.push_info(crate::t!("No editable objects selected.").as_ref());
return Task::none();
}
self.push_undo_snapshot(i, "BLOCK");
let ucs = self.tabs[i].ucs_xform();
let world_to_block = ucs.to_ucs_transform_at(base);
let block_to_world = ucs.to_wcs_transform_at(base);
match self.tabs[i]
.scene
.create_block_from_entities(
&handles,
&name,
&world_to_block,
&block_to_world,
)
{
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(crate::tf!("Block \"{name}\" created.").as_ref());
self.refresh_properties();
}
Err(err) => {
self.discard_last_undo_entry(i);
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");
let pending = self.begin_undo(i, label, 1, true);
let layer = self.tabs[i].active_layer.clone();
let new_handle = self.tabs[i].scene.add_hatch(hatch, Some(&layer), None);
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();
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
}
CmdResult::CommitStyledHatch {
hatch,
color,
transparency,
} => {
let label = self.history_label_from_active_cmd(i, "HATCH");
let pending = self.begin_undo(i, label, 1, true);
let layer = self.tabs[i].active_layer.clone();
let new_handle = self.tabs[i].scene.add_hatch(
hatch,
Some(&layer),
Some((color, transparency)),
);
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();
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
}
CmdResult::CommitHatchWithBoundaries {
mut hatch,
boundaries,
entity_style,
} => {
let label = self.history_label_from_active_cmd(i, "HATCH");
let pending = self.begin_undo(i, label, boundaries.len() + 1, true);
let mut sources = Vec::with_capacity(boundaries.len());
for boundary in boundaries {
let handles = self
.commit_entity_handle(boundary)
.into_iter()
.collect::<Vec<_>>();
sources.push(handles);
}
if let Some(paths) = hatch.boundary_paths.as_mut() {
for (path, handles) in std::sync::Arc::make_mut(paths).iter_mut().zip(&sources)
{
path.boundary_handles = handles.clone();
path.flags.set_external(!handles.is_empty());
}
}
hatch.boundary_sources = Some(std::sync::Arc::new(sources));
let layer = self.tabs[i].active_layer.clone();
let new_handle =
self.tabs[i]
.scene
.add_hatch(hatch, Some(&layer), entity_style);
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();
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
}
CmdResult::CommitHatches {
hatches,
entity_style,
} => {
let label = self.history_label_from_active_cmd(i, "HATCH");
let pending = self.begin_undo(i, label, hatches.len(), true);
let layer = self.tabs[i].active_layer.clone();
for hatch in hatches {
let new_handle = self.tabs[i].scene.add_hatch(
hatch,
Some(&layer),
entity_style.clone(),
);
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();
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
}
CmdResult::BatchCopy(mut handles, transforms) => {
handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle));
if handles.is_empty() {
self.tabs[i].active_cmd = None;
return Task::none();
}
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(crate::tf!("{label}: {count} {noun} created.").as_ref());
self.refresh_properties();
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
}
CmdResult::ReplaceMany(replacements, additions) => {
if let Some((handle, _)) = replacements
.iter()
.find(|(handle, _)| self.tabs[i].scene.is_layer_locked(*handle))
{
self.reject_locked_edit(i, *handle);
self.tabs[i].active_cmd = None;
return Task::none();
}
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.replace_command_entity(i,handle,entities);
}
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(crate::t!("Catchment tagged successfully.").as_ref());
}
self.refresh_properties();
}
CmdResult::ReplaceManyContinue(replacements) => {
if let Some((handle, _)) = replacements
.iter()
.find(|(handle, _)| self.tabs[i].scene.is_layer_locked(*handle))
{
self.reject_locked_edit(i, *handle);
return Task::none();
}
let label = self.history_label_from_active_cmd(i, "TRIM");
self.push_undo_snapshot(i, label);
for (handle, entities) in replacements {
let new_handles = self.replace_command_entity(i,handle,entities);
if let Some(command) = self.tabs[i].active_cmd.as_mut() {
command.on_entity_replaced(handle, &new_handles);
}
}
self.tabs[i].dirty = true;
self.tabs[i].scene.clear_preview_wire();
self.tabs[i].snap_result = None;
if let Some(prompt) = self.tabs[i]
.active_cmd
.as_ref()
.map(|command| command.prompt())
{
self.command_line.push_info(&prompt);
}
self.refresh_properties();
}
CmdResult::UpdateEntityAndFinish { handle, entity } => {
if self.reject_locked_edit(i, handle) {
self.tabs[i].active_cmd = None;
return Task::none();
}
let label = self.history_label_from_active_cmd(i, "EDIT");
self.push_undo_snapshot(i, label);
let is_dimension = matches!(entity, acadrust::EntityType::Dimension(_));
if let Some(current) = self.tabs[i].scene.document.get_entity_mut(handle) {
*current = entity;
if is_dimension {
self.tabs[i].scene.invalidate_dim_block_recorded(handle);
}
self.tabs[i]
.scene
.bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]);
self.tabs[i].dirty = true;
} else {
self.command_line.push_error(
crate::t!("The selected object is no longer available.").as_ref(),
);
}
self.tabs[i].scene.clear_preview_wire();
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.refresh_properties();
}
CmdResult::AddSketchConstraint { kind, refs, driving_param, label } => {
if let Err(message) = self.tabs[i]
.scene
.validate_sketch_constraint(kind, &refs, driving_param.as_ref())
{
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.command_line.push_error(message);
return Task::none();
}
let scope = self.tabs[i].current_sketch_scope();
let constraints_before = self
.tabs[i]
.scene
.sketch_constraint_set(scope)
.cloned()
.unwrap_or_else(|| {
crate::scene::sketch_constraints::SketchConstraintSet::new(scope)
});
let touched: Vec<Handle> = refs.iter().map(|r| r.entity).collect();
let pending = self.begin_undo(i, label, touched.len(), true);
self.tabs[i]
.scene
.record_undo_sketch_constraints_before(scope, constraints_before);
self.tabs[i].scene.sketch_constraint_set_mut(scope).add(kind, refs, driving_param);
let changes: Vec<(Handle, crate::scene::ChangeKind)> = touched
.into_iter()
.map(|h| (h, crate::scene::ChangeKind::Modified))
.collect();
self.tabs[i].scene.bump_entities(&changes);
self.tabs[i].dirty = true;
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.command_line.push_output("Constraint applied.");
self.refresh_properties();
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
}
CmdResult::AddCoincidentConstraint { point_a, point_b, label } => {
let scope = self.tabs[i].current_sketch_scope();
let to_world = |p: glam::DVec3| acadrust::types::Vector3::new(p.x, p.y, p.z);
let resolved_a =
crate::scene::sketch_constraints::nearest_sketch_point(&self.tabs[i].scene.document, scope, to_world(point_a), None);
let resolved_b = resolved_a.and_then(|ref_a| {
crate::scene::sketch_constraints::nearest_sketch_point(&self.tabs[i].scene.document, scope, to_world(point_b), None)
.filter(|ref_b| *ref_b != ref_a)
});
match (resolved_a, resolved_b) {
(Some(ref_a), Some(ref_b)) => {
return self.apply_cmd_result(CmdResult::AddSketchConstraint {
kind: crate::scene::sketch_constraints::ConstraintKind::Coincident,
refs: vec![ref_a, ref_b],
driving_param: None,
label,
});
}
_ => {
self.command_line.push_error(
"Coincident: pick didn't land on a point (endpoint, center, or vertex) — enable an Endpoint/Center object snap and try again.",
);
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
}
}
}
CmdResult::AddPointOnEntityConstraint { point, target, kind, label } => {
let scope = self.tabs[i].current_sketch_scope();
let to_world = |p: glam::DVec3| acadrust::types::Vector3::new(p.x, p.y, p.z);
let resolved = crate::scene::sketch_constraints::nearest_sketch_point(&self.tabs[i].scene.document, scope, to_world(point), Some(target));
match resolved {
Some(point_ref) => {
use crate::scene::sketch_constraints::{ConstraintKind, SketchRef};
// `CenterPoint` addresses its whole-entity side via
// the center marker (same solve as Coincident /
// Concentric — `ConstraintKind::CenterPoint`'s own
// doc comment); `Midpoint`/`PointOnCurve` address it
// as a whole entity.
let target_ref =
if kind == ConstraintKind::CenterPoint { SketchRef::center(target) } else { SketchRef::whole(target) };
return self.apply_cmd_result(CmdResult::AddSketchConstraint {
kind,
refs: vec![point_ref, target_ref],
driving_param: None,
label,
});
}
None => {
self.command_line.push_error(
"Pick didn't land on a point (endpoint, center, or vertex) — enable an Endpoint/Center object snap and try again.",
);
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
}
}
}
CmdResult::AddEqualDistanceConstraint { points, label } => {
let scope = self.tabs[i].current_sketch_scope();
let to_world = |p: glam::DVec3| acadrust::types::Vector3::new(p.x, p.y, p.z);
let mut refs = Vec::with_capacity(4);
for p in points {
let Some(r) = crate::scene::sketch_constraints::nearest_sketch_point(&self.tabs[i].scene.document, scope, to_world(p), None)
else {
refs.clear();
break;
};
refs.push(r);
}
if refs.len() == 4 {
return self.apply_cmd_result(CmdResult::AddSketchConstraint {
kind: crate::scene::sketch_constraints::ConstraintKind::EqualDistance,
refs,
driving_param: None,
label,
});
}
self.command_line.push_error(
"Equal Distance: a pick didn't land on a point (endpoint, center, or vertex) — enable an Endpoint/Center object snap and try again.",
);
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
}
CmdResult::ReassociateCenterMark { target, source, point } => {
if self.reject_locked_edit(i, target) {
return Task::none();
}
self.push_undo_snapshot(i, "CENTERREASSOCIATE");
if self.tabs[i].scene.reassociate_center_mark(target, source, point) {
self.tabs[i].dirty = true;
self.command_line.push_output(
crate::t!("CENTERREASSOCIATE: center mark associated.").as_ref(),
);
} else {
self.command_line.push_error(
crate::t!("CENTERREASSOCIATE: the selected source is not circular.").as_ref(),
);
}
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.tabs[i].scene.clear_preview_wire();
self.refresh_properties();
}
CmdResult::EditDimensionBreak {
dimensions,
operation,
} => {
self.push_undo_snapshot(i, "DIMBREAK");
let result = apply_dimbreak(&mut self.tabs[i].scene, &dimensions, operation);
if result.changed.is_empty() {
self.command_line.push_info(&result.message);
} else {
for handle in &result.changed {
self.tabs[i].scene.invalidate_dim_block_recorded(*handle);
}
let changes = result
.changed
.iter()
.copied()
.map(|handle| (handle, crate::scene::ChangeKind::Modified))
.collect::<Vec<_>>();
self.tabs[i].scene.bump_entities(&changes);
self.tabs[i].dirty = true;
self.command_line.push_output(&result.message);
}
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
}
CmdResult::EditDimensionJog { dimension, point } => {
let valid = matches!(
self.tabs[i].scene.document.get_entity(dimension),
Some(acadrust::EntityType::Dimension(
acadrust::entities::Dimension::Linear(_)
| acadrust::entities::Dimension::Aligned(_)
))
);
if !valid {
self.command_line.push_error(
crate::t!("DIMJOGLINE: select a linear or aligned dimension.").as_ref(),
);
} else if !self.reject_locked_edit(i, dimension) {
self.push_undo_snapshot(i, "DIMJOGLINE");
let values = point.map(|point| {
use acadrust::xdata::XDataValue;
vec![
XDataValue::Integer16(387),
XDataValue::Integer16(3),
XDataValue::Integer16(389),
XDataValue::Point3D(acadrust::types::Vector3::new(
point.x, point.y, point.z,
)),
]
});
crate::scene::view::dispatch::set_entity_xdata(
&mut self.tabs[i].scene.document,
dimension,
"ACAD_DSTYLE_DIMJAG_POSITION",
values,
);
self.tabs[i].scene.invalidate_dim_block_recorded(dimension);
self.tabs[i]
.scene
.bump_entities(&[(dimension, crate::scene::ChangeKind::Modified)]);
self.tabs[i].dirty = true;
self.command_line.push_output(
if point.is_some() {
crate::t!("DIMJOGLINE: jog added.")
} else {
crate::t!("DIMJOGLINE: jog removed.")
}
.as_ref(),
);
}
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
}
CmdResult::SpaceDimensions {
base,
others,
spacing,
} => {
self.push_undo_snapshot(i, "DIMSPACE");
if apply_dimspace(&mut self.tabs[i].scene, base, &others, spacing) {
self.tabs[i].dirty = true;
self.command_line
.push_output(crate::t!("DIMSPACE Spacing adjusted.").as_ref());
}
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
}
CmdResult::ReplaceEntity(handle, new_entities) => {
if self.reject_locked_edit(i, handle) {
return Task::none();
}
// 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();
}
}
}
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();
// Rebuild replaced dimensions from edited data.
for &nh in &new_handles {
if matches!(
self.tabs[i].scene.document.get_entity(nh),
Some(acadrust::EntityType::Dimension(_))
) {
self.tabs[i].scene.invalidate_dim_block_recorded(nh);
}
}
let pedit_entities = if self.tabs[i].active_cmd.as_ref().is_some_and(|command| command.name() == "PEDIT") {
new_handles.iter().filter_map(|new| self.tabs[i].scene.document.get_entity(*new).cloned()).collect::<Vec<_>>()
} else { Vec::new() };
if let Some(cmd) = &mut self.tabs[i].active_cmd {
cmd.on_entity_replaced(handle, &new_handles);
for entity in pedit_entities { cmd.inject_picked_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);
}
}
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 insert_name = match &entity {
acadrust::EntityType::Insert(ins) => Some(ins.block_name.clone()),
_ => None,
};
let label = self.history_label_from_active_cmd(i, "INSERT");
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;
if let Some(n) = insert_name {
self.record_block_insert(&n);
}
self.restore_pre_cmd_tangent();
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
}
} else {
let completed = self.tabs[i]
.active_cmd
.as_mut()
.and_then(|cmd| cmd.attreq_set_attdefs(attdefs));
if let Some(entity) = completed {
return self.apply_cmd_result(CmdResult::CommitAndExit(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::CommitLiveEntity(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();
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
if let Some(h) = handle {
// Keep the live document Arc unique while later vertices
// replace the geometry in place. The final Arc is captured
// by UpdateLiveEntity when the command completes.
self.defer_live_entity_history_after(i, h);
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,
} => {
let tracks_draw_anchor = matches!(
&entity,
acadrust::EntityType::Line(_)
| acadrust::EntityType::Arc(_)
| acadrust::EntityType::LwPolyline(_)
| acadrust::EntityType::Polyline(_)
| acadrust::EntityType::Polyline2D(_)
| acadrust::EntityType::Polyline3D(_)
);
// 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
.bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]);
self.tabs[i].dirty = true;
}
if tracks_draw_anchor {
self.tabs[i].last_draw_anchor = Some(handle);
}
if finish {
self.finish_live_entity_history(i, handle);
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::FinalizeLiveEntity(handle) => {
// The live geometry already matches the command's committed
// vertices. Close the deferred history image and UI state
// without publishing a redundant Modified delta.
self.finish_live_entity_history(i, handle);
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::RemoveLiveEntity(handle) => {
// The command backed off below a valid entity (PLINE Undo at
// one remaining vertex): take the live entity out of the
// document, drop its provisional history entry and keep
// prompting. A later second point creates one fresh entry.
self.tabs[i].scene.erase_entities(&[handle]);
if self.tabs[i]
.last_draw_anchor
.is_some_and(|anchor_handle| anchor_handle == handle)
{
self.tabs[i].last_draw_anchor = None;
}
self.discard_last_undo_entry(i);
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);
}
}
cancel @ (CmdResult::Cancel | CmdResult::CancelForSpaceChange) => {
let space_changed = matches!(cancel, CmdResult::CancelForSpaceChange);
self.tabs[i].scene.clear_preview_wire();
if !space_changed && self.tabs[i].suspended_cmd.is_some() {
self.tabs[i].active_cmd = self.tabs[i].suspended_cmd.take();
let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt());
if let Some(p) = prompt {
self.command_line.push_info(&p);
}
let opts = self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.options())
.unwrap_or_default();
self.command_line.set_step_options(opts);
self.refresh_active_cmd_preview(i);
} else {
self.tabs[i].suspended_cmd = None;
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.restore_pre_cmd_tangent();
self.command_line.push_info(crate::t!(if space_changed {
"Command cancelled because the active drawing space changed."
} else {
"Command cancelled."
}).as_ref());
}
}
CmdResult::SelectByPath {
path,
closed,
crossing,
} => {
// The command picked the path; the hit test lives here, where
// the camera and the drawing's geometry are. Everything after
// matches what a lasso does, Remove included.
let canvas = self.tabs[i].scene.selection.borrow().vp_size;
let bounds = iced::Rectangle {
x: 0.0,
y: 0.0,
width: canvas.0,
height: canvas.1,
};
let edit_cam = self.tabs[i]
.scene
.viewport_edit_frame(canvas)
.map(|(cam, _)| cam);
let (view_rot, eye, all_wires) = self.pick_view(i, &edit_cam, bounds);
let screen: Vec<iced::Point> = path
.iter()
.map(|p| {
crate::scene::pick::hit_test::world_to_screen(
glam::DVec3::new(p[0], p[1], 0.0),
view_rot,
eye,
bounds,
)
})
.collect();
let handles = self.tabs[i].scene.path_hit_handles(
&screen,
crossing,
!closed,
all_wires,
view_rot,
eye,
bounds,
|point| self.cursor_model_point(i, &edit_cam, point, bounds),
);
if self.select_remove_mode {
for h in &handles {
self.tabs[i].scene.deselect_entity(*h);
}
} else {
for h in &handles {
self.tabs[i].scene.select_entity(*h, false);
}
self.tabs[i].scene.expand_selection_for_groups(&handles);
}
self.refresh_properties();
let selected: Vec<Handle> = self.tabs[i]
.scene
.selected_entities()
.into_iter()
.map(|(h, _)| h)
.collect();
let count = handles.len();
self.command_line
.push_info(crate::tf!("{count} object(s) found.").as_ref());
return self.feed_command(StepInput::SelectionComplete(selected));
}
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::EditTableCell { handle, point } => {
// TABLEDIT's pick: end the pick phase and hand (table, point)
// to the shared cell-edit launcher. A miss or a locked cell
// re-prompts until a valid table cell is selected.
use crate::modules::annotate::table_cmd::{table_cell_at, TableCellEditStart};
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.tabs[i].scene.clear_preview_wire();
self.restore_pre_cmd_tangent();
if handle == acadrust::Handle::NULL && self.selection_cycling {
let mut candidates = Vec::new();
for entity in self.tabs[i].scene.document.entities() {
if let acadrust::EntityType::Table(table) = entity {
let h = table.common.handle;
let style = table.table_style_handle.and_then(|style_handle| {
self.tabs[i]
.scene
.document
.objects
.get(&style_handle)
.and_then(|object| match object {
acadrust::objects::ObjectType::TableStyle(style) => Some(style),
_ => None,
})
});
if table_cell_at(table, style, point).is_some() {
candidates.push(h);
}
}
}
if candidates.len() >= 2 {
self.cycle_candidates = Some((self.quick_properties_anchor, candidates));
return Task::none();
}
}
match self.begin_table_cell_edit(i, handle, point) {
TableCellEditStart::Started => {}
TableCellEditStart::LockedCell => {
self.command_line.push_error(
crate::t!("The cell is content locked.").as_ref(),
);
let _ = self.dispatch_command("TABLEDIT");
}
TableCellEditStart::NoCell => {
self.command_line.push_error(
crate::t!("No editable table cell picked.").as_ref(),
);
let _ = self.dispatch_command("TABLEDIT");
}
}
}
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());
let dest: Vec<_> = dest
.into_iter()
.filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle))
.collect();
if dest.is_empty() {
self.command_line
.push_info(crate::t!("No editable objects selected.").as_ref());
} else 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(crate::tf!("Layer matched to \"{layer}\".").as_ref());
self.sync_ribbon_layers();
} else {
self.command_line.push_error(crate::t!("Source object not found.").as_ref());
}
}
CmdResult::MatchProperties { mut dest, src } => {
dest.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle));
if dest.is_empty() {
return Task::none();
}
// The command stays active after each apply so more targets
// can keep being picked; Enter / Esc ends it (#362).
let src_clone = self.tabs[i].scene.document.get_entity(src).cloned();
let src_common = src_clone.as_ref().map(|e| e.common().clone());
let thickness = src_clone
.as_ref()
.and_then(crate::scene::view::dispatch::entity_thickness);
// This application record is the hatch background colour.
// Keep the outer Option to distinguish "source is not a
// hatch" from "source hatch has no background".
let hatch_background_xdata: Option<Option<Vec<acadrust::xdata::XDataValue>>> =
match src_clone.as_ref() {
Some(acadrust::EntityType::Hatch(h)) => Some(
h.common
.extended_data
.get_record("HATCHBACKGROUNDCOLOR")
.map(|r| r.values.clone()),
),
_ => None,
};
// Dimension-style overrides ride the ACAD record, identified
// by a leading DSTYLE string. Matching replicates that payload
// (or clears the destination when the source has none).
let dstyle_xdata: Option<Vec<(i16, acadrust::xdata::XDataValue)>> = src_clone
.as_ref()
.filter(|e| {
matches!(
e,
acadrust::EntityType::Dimension(_) | acadrust::EntityType::Leader(_)
)
})
.map(|e| crate::entities::dim_override::pairs(&e.common().extended_data));
if let Some(common) = src_common {
self.apply_property_op(i, "MATCHPROP", &dest, |app, handle| {
let mut is_dim = false;
let mut is_hatch = false;
if let Some(e) = app.tabs[i].scene.document.get_entity_mut(handle) {
e.as_entity_mut().set_layer(common.layer.clone());
crate::scene::view::dispatch::apply_color(e, common.color);
crate::scene::view::dispatch::apply_line_weight(e, common.line_weight);
{
let dst_common = e.common_mut();
dst_common.linetype = common.linetype.clone();
dst_common.linetype_handle = common.linetype_handle;
dst_common.linetype_scale = common.linetype_scale;
dst_common.transparency = common.transparency.clone();
dst_common.color_name = common.color_name.clone();
dst_common.color_book_handle = common.color_book_handle;
dst_common.full_visual_style_handle =
common.full_visual_style_handle;
dst_common.face_visual_style_handle =
common.face_visual_style_handle;
dst_common.edge_visual_style_handle =
common.edge_visual_style_handle;
dst_common.material_flags = common.material_flags;
dst_common.material_handle = common.material_handle;
dst_common.shadow_flags = common.shadow_flags;
dst_common.plotstyle_flags = common.plotstyle_flags;
dst_common.plotstyle_handle = common.plotstyle_handle;
}
if let Some(value) = thickness {
crate::scene::view::dispatch::set_entity_thickness(e, value);
}
if let Some(se) = &src_clone {
is_dim = matches!(e, acadrust::EntityType::Dimension(_));
is_hatch = matches!(e, acadrust::EntityType::Hatch(_));
match_special_props(se, e);
}
}
if is_hatch {
if let Some(values) = &hatch_background_xdata {
crate::scene::view::dispatch::set_entity_xdata(
&mut app.tabs[i].scene.document,
handle,
"HATCHBACKGROUNDCOLOR",
values.clone(),
);
}
}
// Dim-style overrides follow the style for dimension /
// leader destinations — through set_entity_xdata so no
// stale raw record survives.
if dstyle_xdata.is_some()
|| matches!(
app.tabs[i].scene.document.get_entity(handle),
Some(
acadrust::EntityType::Dimension(_)
| acadrust::EntityType::Leader(_)
)
)
{
if matches!(
app.tabs[i].scene.document.get_entity(handle),
Some(
acadrust::EntityType::Dimension(_)
| acadrust::EntityType::Leader(_)
)
) && matches!(
src_clone,
Some(
acadrust::EntityType::Dimension(_)
| acadrust::EntityType::Leader(_)
)
) {
crate::entities::dim_override::replace(
&mut app.tabs[i].scene.document,
handle,
dstyle_xdata.clone().unwrap_or_default(),
);
}
}
// A restyled dimension renders from its baked *D block —
// drop the stale block so the new style shows (#398).
if is_dim {
app.tabs[i].scene.invalidate_dim_block_recorded(handle);
}
// Hatch fills render from a prebuilt model (#415).
app.tabs[i].scene.refresh_fill_model(handle);
});
// Color / linetype / lineweight are baked into the cached
// wires at tessellation time; re-tessellate only the matched
// objects instead of rebuilding a large drawing.
let changes: Vec<_> = dest
.iter()
.copied()
.map(|handle| (handle, crate::scene::ChangeKind::Modified))
.collect();
self.tabs[i].scene.bump_entities(&changes);
self.command_line
.push_info(crate::tf!("Properties matched to {} object(s).", dest.len()).as_ref());
// 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(crate::t!("Source object not found.").as_ref());
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(crate::t!("Clipboard is empty.").as_ref());
} 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(crate::tf!("{count} object(s) pasted.").as_ref());
}
}
CmdResult::CreateGroup { mut handles, name } => {
handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle));
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.tabs[i].scene.clear_preview_wire();
if handles.is_empty() {
return Task::none();
}
let undo = self.begin_group_undo(i, "GROUP");
self.tabs[i].scene.create_group(name.clone(), handles);
self.tabs[i].dirty = true;
self.commit_group_undo(i, undo);
self.command_line
.push_info(crate::tf!("Group \"{}\" created.", name).as_ref());
}
CmdResult::DeleteGroups { mut handles } => {
handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle));
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.tabs[i].scene.clear_preview_wire();
if handles.is_empty() {
return Task::none();
}
let undo = self.begin_group_undo(i, "UNGROUP");
let count = self.tabs[i].scene.delete_groups_containing(&handles);
self.tabs[i].dirty = true;
self.commit_group_undo(i, undo);
if count > 0 {
self.command_line
.push_info(crate::tf!("{} group(s) dissolved.", count).as_ref());
} else {
self.command_line
.push_info(crate::t!("No groups found for selected objects.").as_ref());
}
}
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(crate::tf!(
"VPLAYER: {frozen_count} layer(s) frozen in viewport."
).as_ref());
}
if thawed_count > 0 {
self.command_line.push_info(crate::tf!(
"VPLAYER: {thawed_count} layer(s) thawed in viewport."
).as_ref());
}
// 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.remember_current_view();
self.tabs[i]
.scene
.zoom_to_window(p1.as_vec3(), p2.as_vec3());
self.command_line.push_output(crate::t!("Zoom Window").as_ref());
}
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::ReportMeasurement(msg) => {
self.tabs[i].snap_result = None;
self.tabs[i].scene.clear_preview_wire();
self.refresh_area_preview(i);
self.command_line.push_output(&msg);
if let Some(prompt) = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()) {
self.command_line.push_info(&prompt);
}
}
CmdResult::ReportError(msg) => {
self.tabs[i].snap_result = None;
self.tabs[i].scene.clear_preview_wire();
self.command_line.push_error(&msg);
if let Some(prompt) = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()) {
self.command_line.push_info(&prompt);
}
}
CmdResult::ReportMeasurementAndDeselect(msg) => {
self.tabs[i].snap_result = None;
self.tabs[i].scene.deselect_all();
self.tabs[i].scene.clear_preview_wire();
self.refresh_area_preview(i);
self.refresh_properties();
self.command_line.push_output(&msg);
if let Some(prompt) = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()) {
self.command_line.push_info(&prompt);
}
}
CmdResult::DeselectAndContinue => {
self.tabs[i].snap_result = None;
self.tabs[i].scene.deselect_all();
self.tabs[i].scene.clear_preview_wire();
self.refresh_area_preview(i);
self.refresh_properties();
if let Some(prompt) = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()) {
self.command_line.push_info(&prompt);
}
}
CmdResult::AlignSelected {
mut handles,
src1,
dst1,
angle_rad,
scale,
} => {
handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle));
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,
axis: glam::DVec3::Z,
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(crate::t!("ALIGN: applied.").as_ref());
self.refresh_properties();
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
}
}
CmdResult::LengthenEntity {
handle,
pick_pt,
mode,
} => {
if self.reject_locked_edit(i, handle) {
return Task::none();
}
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(crate::t!("LENGTHEN: applied.").as_ref());
self.refresh_properties();
}
None => {
self.command_line
.push_error(crate::t!("LENGTHEN: entity type not supported.").as_ref());
}
}
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, marker } => {
use crate::modules::draw::inquiry::divide::divide_entity;
let pts = self.tabs[i]
.scene
.document
.get_entity(handle)
.map(|e| divide_entity(e, n, marker.as_ref()))
.unwrap_or_default();
let count = pts.len();
if count > 0 {
self.push_undo_snapshot(i, "DIVIDE");
let layer = self.tabs[i].active_layer.clone();
for mut p in pts {
p.as_entity_mut().set_layer(layer.clone());
self.tabs[i].scene.add_entity(p);
}
self.tabs[i].dirty = true;
self.command_line
.push_output(crate::tf!("DIVIDE: {count} marker(s) placed.").as_ref());
} else {
self.command_line
.push_error(crate::t!("DIVIDE: entity type not supported or N < 2.").as_ref());
}
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,
pick_point,
marker,
} => {
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, pick_point, marker.as_ref()))
.unwrap_or_default();
let count = pts.len();
if count > 0 {
self.push_undo_snapshot(i, "MEASURE");
let layer = self.tabs[i].active_layer.clone();
for mut p in pts {
p.as_entity_mut().set_layer(layer.clone());
self.tabs[i].scene.add_entity(p);
}
self.tabs[i].dirty = true;
self.command_line
.push_output(crate::tf!("MEASURE: {count} marker(s) placed.").as_ref());
} else {
self.command_line
.push_output(crate::t!("MEASURE: 0 markers placed.").as_ref());
}
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 } => return self.apply_pedit_result(i, handle, op),
CmdResult::JoinToSource { source, handles } => {
if self.reject_locked_edit(i, source) { return Task::none(); }
let joined = self.tabs[i].scene.document.get_entity(source).and_then(|entity| {
let candidates: Vec<_> = handles.iter().filter(|handle| **handle != source && !self.tabs[i].scene.is_layer_locked(**handle))
.filter_map(|handle| self.tabs[i].scene.document.get_entity(*handle).map(|entity| (*handle,entity))).collect();
crate::modules::draw::modify::join::join_to_source(entity,&candidates)
});
if let Some((replacement, consumed)) = joined {
self.push_undo_snapshot(i,"JOIN");
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(source) { *entity = replacement; }
self.tabs[i].scene.erase_entities(&consumed);
self.tabs[i].scene.refresh_fill_model(source);
self.tabs[i].scene.bump_entities(&[(source,crate::scene::ChangeKind::Modified)]);
self.tabs[i].dirty = true;
self.command_line.push_output(&format!("JOIN: {} objects joined to source.",consumed.len()));
self.refresh_properties();
} else { self.command_line.push_info("JOIN: no compatible objects joined to source."); }
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::JoinEntities(handles) => {
if let Some(handle) = handles
.iter()
.find(|handle| self.tabs[i].scene.is_layer_locked(**handle))
{
self.reject_locked_edit(i, *handle);
return Task::none();
}
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(crate::tf!(
"JOIN: {count_in} object(s) joined into {count_out}."
).as_ref());
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(
crate::t!("JOIN: objects don't form a single connected chain, or contain an unsupported type / tilted arc.").as_ref(),
);
}
}
}
CmdResult::BreakEntity { handle, p1, p2 } => {
if self.reject_locked_edit(i, handle) {
return Task::none();
}
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 unchanged = frags.len() == 1 && self.tabs[i].scene.document.get_entity(handle)
.is_some_and(|original| {
let mut fragment = frags[0].clone();
fragment.common_mut().handle = handle;
&fragment == original
});
if unchanged {
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("BREAK: no geometry changed.");
return Task::none();
}
let label = self.history_label_from_active_cmd(i, "BREAK");
self.push_undo_snapshot(i, label);
let count = frags.len();
let owner = self.tabs[i].scene.document.get_entity(handle)
.map(|entity| entity.common().owner_handle);
let mut fragments = frags.into_iter();
if let Some(mut first) = fragments.next() {
first.common_mut().handle = handle;
self.tabs[i].scene.update_entity(first);
for mut fragment in fragments {
fragment.common_mut().handle = Handle::NULL;
if let Some(owner) = owner { fragment.common_mut().owner_handle = owner; }
self.tabs[i].scene.add_entity(fragment);
}
} else {
self.tabs[i].scene.erase_entities(&[handle]);
}
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(crate::tf!("BREAK: {} fragment(s).", count).as_ref());
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(crate::t!("BREAK: entity type not supported.").as_ref());
}
}
}
CmdResult::SetPlotWindow { p1, p2 } => {
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(crate::tf!("Plot window: {x0:.2},{y0:.2} to {x1:.2},{y1:.2}").as_ref());
// 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 {
// PLOTWINDOW always describes the plotted layout. In MSPACE
// the command points are model coordinates, so map them back
// through the active floating viewport first.
let p1 = self.tabs[i].scene.model_to_paper(p1);
let p2 = self.tabs[i].scene.model_to_paper(p2);
let x1 = p1.x.min(p2.x);
let y1 = p1.y.min(p2.y);
let x2 = p1.x.max(p2.x);
let y2 = p1.y.max(p2.y);
self.plot_window = Some((x1, y1, x2, y2));
self.command_line.push_output(crate::tf!(
"Plot window: {x1:.2},{y1:.2} to {x2:.2},{y2:.2}"
).as_ref());
self.plot_dialog.area = "Window".to_string();
self.active_modal = Some(super::ModalKind::Plot);
}
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 {
mut handles,
windows,
} => {
// Accumulate every entity touched by any crossing window. Keep STRETCH
// in its selection stage; Enter is what advances to the base point.
{
let scene = &self.tabs[i].scene;
for (win_min, win_max) in &windows {
handles.extend(
scene
.interaction_handles_in_world_aabb([
win_min.x,
win_min.y,
win_max.x,
win_max.y,
])
.into_iter()
.filter(|&handle| !scene.is_layer_locked(handle)),
);
}
}
handles.sort_unstable_by_key(|handle| handle.value());
handles.dedup();
if handles.is_empty() {
self.command_line.push_output(
crate::t!("STRETCH: nothing crosses the window.").as_ref(),
);
} else {
// Visual feedback must only highlight entities that actually have a
// stretchable point inside one of the crossing windows. `handles` can
// contain the whole candidate/preselection set, while `windows` decides
// which vertices will really move.
let point_inside = |point: glam::DVec3| {
const EPS: f64 = 1.0e-9;
windows.iter().any(|(win_min, win_max)| {
point.x >= win_min.x - EPS
&& point.x <= win_max.x + EPS
&& point.y >= win_min.y - EPS
&& point.y <= win_max.y + EPS
&& point.z >= win_min.z - EPS
&& point.z <= win_max.z + EPS
})
};
let mut visual_handles = Vec::new();
for &handle in &handles {
let wires = self.tabs[i].scene.wire_models_for(&[handle]);
let affected = wires.iter().any(|wire| {
// LINE / LWPOLYLINE / other vertex-based entities.
let vertex_hit = wire
.key_vertices
.iter()
.any(|point| point_inside(glam::DVec3::from_array(*point)));
if vertex_hit {
return true;
}
// Whole-object STRETCH cases such as circles/arcs/inserts/points:
// use only their meaningful anchor snap, not quadrants or
// tessellated display points.
wire.snap_pts.iter().any(|(point, hint)| {
matches!(
hint,
crate::scene::model::wire_model::SnapHint::Center
| crate::scene::model::wire_model::SnapHint::Insertion
| crate::scene::model::wire_model::SnapHint::Node
) && point_inside(*point)
})
});
if affected {
visual_handles.push(handle);
}
}
self.tabs[i].scene.deselect_all();
self.tabs[i].scene.select_entities(&visual_handles);
self.refresh_selected_grips();
// STRETCH temporary grips: keep only the actual vertices/endpoints
// that lie inside one of the accumulated crossing windows.
//
// Mid-segment grips are deliberately excluded: STRETCH moves vertices,
// not the midpoint affordances used by normal grip editing.
let mut stretch_grips = Vec::new();
let mut stretch_grip_handles = Vec::new();
for (handle, grip) in std::mem::take(&mut self.tabs[i].selected_grip_handles)
.into_iter()
.zip(std::mem::take(&mut self.tabs[i].selected_grips))
{
if !grip.is_midpoint && point_inside(grip.world) {
stretch_grip_handles.push(handle);
stretch_grips.push(grip);
}
}
self.tabs[i].selected_grip_handles = stretch_grip_handles;
self.tabs[i].selected_grips = stretch_grips;
}
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_windows(
handles,
wires,
windows,
);
self.command_line.push_info(&CadCommand::prompt(&cmd));
self.tabs[i].active_cmd = Some(Box::new(cmd));
}
CmdResult::StretchEntities {
mut handles,
windows,
delta,
} => {
handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle));
if handles.is_empty() {
self.tabs[i].active_cmd = None;
return Task::none();
}
let structural = handles.iter().any(|handle| {
matches!(
self.tabs[i].scene.document.get_entity(*handle),
Some(acadrust::EntityType::Dimension(_))
)
});
let pending = self.begin_undo(i, "STRETCH", handles.len(), !structural);
let mut count = 0usize;
let mut changed_handles = Vec::new();
// 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 {
windows.iter().any(|(win_min, win_max)| {
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 before = self.tabs[i].scene.document.get_entity_arc(*handle);
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) => {
let Some(mut world) =
crate::entities::curve::lwpolyline_world_xy(p)
else {
continue;
};
for v in &mut world.vertices {
if in_win(v.location.x, v.location.y) {
v.location.x += dx;
v.location.y += dy;
stretched = true;
}
}
if stretched {
*p = world;
}
}
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::Viewport(vp) => {
stretched = windows.iter().any(|(win_min, win_max)| {
crate::entities::viewport::stretch(
vp,
*win_min,
*win_max,
delta,
)
});
}
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.feature_location);
stretched |= mv(&mut d.leader_endpoint);
}
Dimension::Arc(d) => {
stretched |= mv(&mut d.definition_point);
stretched |= mv(&mut d.first_extension_point);
stretched |= mv(&mut d.second_extension_point);
stretched |= mv(&mut d.center_point);
if d.has_leader {
stretched |= mv(&mut d.first_leader_point);
stretched |= mv(&mut d.second_leader_point);
}
}
Dimension::LargeRadial(d) => {
stretched |= mv(&mut d.definition_point);
stretched |= mv(&mut d.chord_point);
stretched |= mv(&mut d.override_center);
stretched |= mv(&mut d.jog_point);
}
}
// 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 {
if let Some(before) = before {
self.tabs[i].scene.record_undo_before(*handle, Some(before));
}
self.tabs[i].scene.mark_entity_dirty(*handle);
changed_handles.push(*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 {
self.tabs[i].scene.invalidate_dim_block_recorded(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 {
let changes: Vec<_> = changed_handles
.iter()
.map(|&handle| (handle, crate::scene::ChangeKind::Modified))
.collect();
self.tabs[i].scene.bump_entities(&changes);
}
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(crate::tf!("STRETCH: {count} entity(ies) stretched.").as_ref());
self.refresh_properties();
if let Some(pending) = pending {
self.commit_undo_delta(i, pending);
}
}
// ── EXTRUDE ────────────────────────────────────────────────────
CmdResult::ExtrudeEntities {
handles,
extent,
mode,
taper_angle,
color: _,
} => {
let delete_sources = self.delete_objects != 0;
if handles.is_empty()
|| handles
.iter()
.any(|handle| self.reject_locked_edit(i, *handle))
{
self.tabs[i].active_cmd = None;
return Task::none();
}
use crate::command::{ExtrudeExtent, ExtrudeMode};
use crate::modules::insert::solid3d_cmds::{
empty_extruded_surface, empty_solid3d,
};
use crate::scene::model::sweep_model;
let path = match extent {
ExtrudeExtent::Path(handle) => self.tabs[i]
.scene
.document
.get_entity(handle)
.cloned()
.map(|entity| (handle, entity)),
_ => None,
};
if matches!(extent, ExtrudeExtent::Path(_)) && path.is_none() {
self.command_line
.push_error(crate::t!("EXTRUDE: path entity not found.").as_ref());
self.tabs[i].active_cmd = None;
return Task::none();
}
let pending = self.begin_undo(i, "EXTRUDE", handles.len(), true);
let mut created_handles = Vec::new();
let mut consumed = Vec::new();
let mut failed = 0usize;
for handle in &handles {
let Some(entity) = self.tabs[i].scene.document.get_entity(*handle).cloned() else {
failed += 1;
continue;
};
let Some((profile, closed)) = sweep_model::extrusion_profile_of(&entity) else {
failed += 1;
continue;
};
let direction = match extent {
ExtrudeExtent::Height(height) => profile.plane.normal().map(|normal| {
glam::DVec3::new(
normal[0] * height,
normal[1] * height,
normal[2] * height,
)
}),
ExtrudeExtent::Direction(direction) => Some(direction),
ExtrudeExtent::Path(_) => None,
};
let path_direction = path
.as_ref()
.and_then(|(_, path)| sweep_model::straight_path_direction(path))
.map(glam::DVec3::from_array);
// The creation mode controls closed profiles. Open curves
// always create sheet surfaces.
let creates_surface = matches!(mode, ExtrudeMode::Surface) || !closed;
let body = match (creates_surface, &path, direction) {
(false, Some((_, path)), _) => {
sweep_model::extruded_along_path(&entity, path, taper_angle)
}
(false, None, Some(direction)) => {
sweep_model::extruded_direction(
&entity,
direction.to_array(),
taper_angle,
)
}
(true, None, Some(direction)) => {
sweep_model::extruded_surface(
&entity,
direction.to_array(),
taper_angle,
)
}
(true, Some(_), _) => path_direction.and_then(|direction| {
sweep_model::extruded_surface(
&entity,
direction.to_array(),
taper_angle,
)
}),
_ => None,
};
let Some(body) = body else {
failed += 1;
continue;
};
let created = if creates_surface {
let direction = direction
.or(path_direction)
.unwrap_or(glam::DVec3::ZERO);
let history = path
.is_none()
.then(|| {
sweep_model::extrusion_history(
&entity,
None,
direction.to_array(),
taper_angle,
profile.plane.origin,
)
})
.flatten();
let mut surface = empty_extruded_surface(direction, taper_angle);
if let (
Some(acadrust::objects::SolidHistoryOperation::Extrusion(value)),
acadrust::EntityType::Surface(entity),
) = (&history, &mut surface)
{
if let Some(data) =
crate::scene::model::solid_history::extrusion_surface_data(value)
{
entity.surface_data = data;
}
}
match history {
Some(history) => {
self.add_surface_model_with_history(surface, body, history)
}
None => self.add_surface_model(surface, body),
}
} else {
let direction = direction.unwrap_or(glam::DVec3::ZERO);
let history = path
.as_ref()
.and_then(|(_, path)| {
sweep_model::sweep_history(
&entity,
path,
taper_angle,
profile.plane.origin,
)
})
.or_else(|| {
sweep_model::extrusion_history(
&entity,
None,
direction.to_array(),
taper_angle,
profile.plane.origin,
)
})
.unwrap_or_else(|| crate::scene::model::solid_history::brep_op(&body));
self.add_solid_model(empty_solid3d(), body, history)
};
if created.is_null() {
failed += 1;
} else {
created_handles.push(created);
if delete_sources {
consumed.push(*handle);
}
}
}
if delete_sources && !created_handles.is_empty() {
consumed.sort_unstable_by_key(|handle| handle.value());
consumed.dedup();
self.tabs[i].scene.erase_entities(&consumed);
}
if !created_handles.is_empty() {
self.tabs[i].scene.deselect_all();
for handle in &created_handles {
self.tabs[i].scene.select_entity(*handle, true);
}
self.tabs[i].dirty = true;
self.command_line.push_output(
crate::tf!(
"EXTRUDE: created %{created} object(s); %{failed} source(s) could not be extruded.",
created = created_handles.len(),
failed = failed
)
.as_ref(),
);
if let Some(pending) = pending {
self.commit_undo_delta(i, pending);
}
} else {
self.command_line.push_error(crate::t!("EXTRUDE: no selected object could be extruded with the requested options.").as_ref());
if let Some(pending) = pending {
self.commit_undo_delta(i, pending);
}
}
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.refresh_properties();
}
CmdResult::ThickenEntities { handles, distance } => {
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.tabs[i].scene.clear_preview_wire();
self.restore_pre_cmd_tangent();
if handles.is_empty() {
self.command_line
.push_output(crate::t!("No surfaces selected.").as_ref());
return Task::none();
}
if !distance.is_finite() {
self.command_line.push_error(
crate::t!("Requires numeric distance or two points.").as_ref(),
);
return Task::none();
}
if distance.abs() <= f64::EPSILON {
return Task::none();
}
use crate::modules::insert::solid3d_cmds::empty_solid3d;
let pending = self.begin_undo(i, "THICKEN", handles.len(), true);
let mut created = 0usize;
let mut failed = 0usize;
let mut self_intersections = 0usize;
for handle in handles {
let Some(acadrust::EntityType::Surface(surface)) =
self.tabs[i].scene.document.get_entity(handle)
else {
failed += 1;
continue;
};
let kernel_distance = if surface.kind
== acadrust::entities::SurfaceKind::Revolved
{
-distance
} else {
distance
};
let body = self.tabs[i]
.scene
.solid_models
.get(&handle)
.cloned()
.or_else(|| {
crate::scene::convert::solid3d_tess::kernel_surface_body(surface)
});
let Some(body) = body else {
failed += 1;
continue;
};
let solid = match cadkernel::brep::thicken(&body, kernel_distance) {
Ok(solid) => solid,
Err(cadkernel::brep::ThickenError::SelfIntersection) => {
failed += 1;
self_intersections += 1;
continue;
}
Err(_) => {
failed += 1;
continue;
}
};
let history = crate::scene::model::solid_history::brep_op(&solid);
if self
.add_solid_model(empty_solid3d(), solid, history)
.is_null()
{
failed += 1;
} else {
created += 1;
}
}
if created > 0 {
self.tabs[i].dirty = true;
}
if self_intersections > 0 {
self.command_line.push_error(&crate::tf!(
"{} surface(s) cannot be thickened because the offset intersects itself.",
self_intersections
));
}
if failed > 0 {
self.command_line.push_output(&crate::tf!(
"THICKEN: created {} solid(s); {} surface(s) could not be thickened exactly.",
created, failed
));
}
if let Some(pending) = pending {
self.commit_undo_delta(i, pending);
}
self.refresh_properties();
}
CmdResult::PresspullPick { handle, point, offset, multiple: _ } => {
self.presspull_pick(handle, point, offset);
}
CmdResult::PresspullApply { targets, distance, color: _ } => {
self.presspull_apply(targets, distance);
}
// ── REVOLVE ────────────────────────────────────────────────────
CmdResult::RevolveEntities {
handles,
axis_start,
axis_end,
angle,
start_angle,
mode,
color: _,
} => {
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();
return Task::none();
}
let delete_sources = self.delete_objects != 0;
use crate::command::ExtrudeMode;
use crate::modules::insert::solid3d_cmds::{
empty_revolved_surface, empty_solid3d,
};
use crate::scene::model::sweep_model;
let from = axis_start.to_array();
let to = axis_end.to_array();
let mut editable_handles = Vec::with_capacity(handles.len());
let mut failed = 0usize;
for handle in handles {
if self.reject_locked_edit(i, handle) {
failed += 1;
} else {
editable_handles.push(handle);
}
}
let pending = if editable_handles.is_empty() {
None
} else {
self.begin_undo(i, "REVOLVE", editable_handles.len(), true)
};
let mut created_handles = Vec::new();
let mut consumed = Vec::new();
for handle in &editable_handles {
let Some(entity) = self.tabs[i].scene.document.get_entity(*handle).cloned()
else {
failed += 1;
continue;
};
let Some((_, closed)) = sweep_model::extrusion_profile_of(&entity) else {
failed += 1;
continue;
};
let creates_surface = mode == ExtrudeMode::Surface || !closed;
let created = if creates_surface {
let Some(body) =
sweep_model::revolved_surface(&entity, from, to, angle, start_angle)
else {
failed += 1;
continue;
};
self.add_surface_model(
empty_revolved_surface(
&entity,
axis_start,
axis_end,
angle,
start_angle,
),
body,
)
} else {
let result = sweep_model::revolve_history(
&entity,
from,
to,
angle,
start_angle,
)
.and_then(|history| {
cadkernel::acis::rebuild_body(&history)
.ok()
.map(|solid| (solid, history))
})
.or_else(|| {
sweep_model::revolved(&entity, from, to, angle, start_angle).map(
|solid| {
let history =
crate::scene::model::solid_history::brep_op(&solid);
(solid, history)
},
)
});
let Some((solid, history)) = result else {
failed += 1;
continue;
};
self.add_solid_model(empty_solid3d(), solid, history)
};
if created.is_null() {
failed += 1;
} else {
created_handles.push(created);
if delete_sources {
consumed.push(*handle);
}
}
}
if delete_sources && !created_handles.is_empty() {
consumed.sort_unstable_by_key(|handle| handle.value());
consumed.dedup();
self.tabs[i].scene.erase_entities(&consumed);
}
if !created_handles.is_empty() {
self.tabs[i].scene.deselect_all();
for handle in &created_handles {
self.tabs[i].scene.select_entity(*handle, true);
}
self.tabs[i].dirty = true;
self.command_line.push_output(
crate::tf!(
"REVOLVE: created %{created} object(s); %{failed} source(s) could not be revolved.",
created = created_handles.len(),
failed = failed
)
.as_ref(),
);
} else {
self.command_line.push_error(
crate::t!("REVOLVE: no selected object could be revolved with the requested options.")
.as_ref(),
);
}
if let Some(pending) = pending {
self.commit_undo_delta(i, pending);
}
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.refresh_properties();
}
// ── SWEEP ─────────────────────────────────────────────────────
CmdResult::SweepEntities {
handles,
path_handle,
mode,
options,
color: _,
} => {
use crate::command::ExtrudeMode;
use crate::modules::insert::solid3d_cmds::empty_solid3d;
use crate::scene::model::sweep_model;
let delete_objects = self.delete_objects;
let delete_profiles = delete_objects != 0;
let path = self.tabs[i].scene.document.get_entity(path_handle).cloned();
let mut profiles = Vec::new();
let mut failed = 0usize;
for handle in handles {
if handle == path_handle || self.reject_locked_edit(i, handle) {
failed += 1;
continue;
}
if let Some(entity) = self.tabs[i].scene.document.get_entity(handle).cloned() {
profiles.push((handle, entity));
} else {
failed += 1;
}
}
let selection = profiles.iter().map(|(_, entity)| entity.clone()).collect::<Vec<_>>();
let options = sweep_model::sweep_selection_options(&selection, options);
let pending = if profiles.is_empty() {
None
} else {
self.begin_undo(i, "SWEEP", profiles.len(), true)
};
let mut created_handles = Vec::new();
let mut consumed = Vec::new();
for (handle, profile) in profiles {
let result = path.as_ref().zip(options).and_then(|(path, options)| {
let record = sweep_model::sweep_record(&profile, path, options)?;
let (_, _, closed) = cadkernel::acis::sweep_profile_geometry(
record.sweep_entity.as_ref()?, record.sweep_entity_transform,
).ok()?;
let surface = mode == ExtrudeMode::Surface || !closed;
let body = cadkernel::acis::rebuild_sweep_with_mode(&record, surface).ok()?;
Some((body, record, surface))
});
let Some((body, record, surface)) = result else {
failed += 1;
continue;
};
let deletes_path = crate::app::delobj_deletes_auxiliary(
delete_objects,
surface,
);
if deletes_path && self.tabs[i].scene.is_layer_locked(path_handle) {
failed += 1;
continue;
}
let created = if surface {
self.add_surface_model(sweep_model::swept_surface_entity(&record), body)
} else {
self.add_solid_model(
empty_solid3d(), body,
acadrust::objects::SolidHistoryOperation::Sweep(record),
)
};
if created.is_null() {
failed += 1;
} else {
created_handles.push(created);
if delete_profiles {
consumed.push(handle);
}
if deletes_path {
consumed.push(path_handle);
}
}
}
if !created_handles.is_empty() {
consumed.sort_unstable_by_key(|handle| handle.value());
consumed.dedup();
self.tabs[i].scene.erase_entities(&consumed);
self.tabs[i].scene.deselect_all();
for handle in &created_handles {
self.tabs[i].scene.select_entity(*handle, true);
}
self.tabs[i].dirty = true;
self.command_line.push_output(crate::tf!(
"SWEEP: created {created} object(s); {failed} source(s) could not be swept.",
created = created_handles.len(), failed = failed
).as_ref());
} else {
self.command_line.push_error(crate::t!("SWEEP: could not sweep the profile along the path.").as_ref());
}
if let Some(pending) = pending {
self.commit_undo_delta(i, pending);
}
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.refresh_properties();
}
// ── LOFT ──────────────────────────────────────────────────────
CmdResult::LoftEntities { sections, guides, path, mode, options, color: _ } => {
use crate::command::LoftSectionSelection;
use crate::modules::insert::solid3d_cmds::empty_solid3d;
use crate::scene::model::loft_command_model;
let mut sources = sections.iter().flat_map(|section| match section {
LoftSectionSelection::Entity(handle) => vec![*handle],
LoftSectionSelection::Join(handles) => handles.clone(),
LoftSectionSelection::Point(_) => Vec::new(),
}).collect::<Vec<_>>();
sources.sort_unstable_by_key(|handle| handle.value());
sources.dedup();
let delete_objects = self.delete_objects;
let delete_sections = delete_objects != 0;
let section_locked = delete_sections
&& sources.iter().any(|handle| self.tabs[i].scene.is_layer_locked(*handle));
let available = sources.iter().chain(guides.iter()).copied().chain(path)
.filter_map(|handle| self.tabs[i].scene.document.get_entity(handle)
.cloned().map(|entity| (handle, entity))).collect::<Vec<_>>();
let result = if section_locked {
Err("LOFT: a source is on a locked layer; disable source deletion or unlock it.".to_string())
} else {
loft_command_model::record(&sections, &guides, path, &available, mode, options)
.and_then(|record| cadkernel::acis::rebuild_loft_with_options(&record).map(|body| (body, record)))
};
match result {
Ok((body, record)) => {
let surface = record.parameters.as_ref().is_some_and(|settings| settings.surface);
let delete_auxiliary = crate::app::delobj_deletes_auxiliary(
delete_objects,
surface,
);
if delete_auxiliary
&& guides.iter().copied().chain(path)
.any(|handle| self.tabs[i].scene.is_layer_locked(handle))
{
self.command_line.push_error(
"LOFT: a source is on a locked layer; disable source deletion or unlock it.",
);
return Task::none();
}
let dirty_before = self.tabs[i].dirty;
let pending = self.begin_undo(i, "LOFT", 1, true);
let created = if surface {
let handle = self.add_surface_model(loft_command_model::surface_entity(&record), body);
if !handle.is_null() {
self.tabs[i].scene.create_solid_history(handle,
acadrust::objects::SolidHistoryOperation::Loft(record));
}
handle
} else {
self.add_solid_model(empty_solid3d(), body,
acadrust::objects::SolidHistoryOperation::Loft(record))
};
if created.is_null() {
// The creation helper already removed its provisional
// result. Discard its empty undo recording as well.
self.tabs[i].scene.take_undo_recording();
self.tabs[i].dirty = dirty_before;
self.command_line.push_error(crate::t!("LOFT could not create a complete display. The source sections were preserved.").as_ref());
return Task::none();
} else {
let mut consumed = if delete_sections {
sources.clone()
} else {
Vec::new()
};
if delete_auxiliary {
consumed.extend(guides.iter().copied());
consumed.extend(path);
}
consumed.sort_unstable_by_key(|handle| handle.value());
consumed.dedup();
self.tabs[i].scene.erase_entities(&consumed);
self.tabs[i].scene.deselect_all();
self.tabs[i].scene.select_entity(created, true);
self.tabs[i].dirty = true;
let message = if surface {
crate::t!("LOFT: surface created.")
} else { crate::t!("LOFT: solid created.") };
self.command_line.push_output(message.as_ref());
}
if let Some(pending) = pending { self.commit_undo_delta(i, pending); }
}
Err(error) => {
self.command_line.push_error(&error);
return Task::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.refresh_properties();
}
CmdResult::SolidEdgeBlend {
handle,
edges,
base_face,
value,
other_value,
fillet,
} => {
let task = self.solid_edge_blend(
handle,
&edges,
base_face,
value,
other_value,
fillet,
);
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 task;
}
CmdResult::SolidShell {
handle,
actions,
distance,
} => {
let task = self.solid_shell(handle, &actions, distance);
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 task;
}
CmdResult::SolidSubtract {
bases,
cutters,
convert_meshes,
} => {
let task = self.solid_subtract(&bases, &cutters, convert_meshes);
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 task;
}
CmdResult::SliceEntities {
targets,
plane,
keep_point,
} => {
let task = self.solid_slice(&targets, plane, keep_point);
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 task;
}
CmdResult::SliceSurfaceEntities {
targets,
cutter,
keep_point,
} => {
let task = self.solid_slice_surface(&targets, *cutter, keep_point);
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 task;
}
CmdResult::HatcheditApply {
handle,
name,
scale,
angle,
operation,
} => {
if self.reject_locked_edit(i, handle) {
return Task::none();
}
if !matches!(
self.tabs[i].scene.document.get_entity(handle),
Some(acadrust::EntityType::Hatch(_))
) {
self.command_line
.push_error(crate::t!("HATCHEDIT: hatch entity not found.").as_ref());
} else {
use crate::command::{CadCommand, HatchEditOperation};
if matches!(&operation,HatchEditOperation::BeginAssociate) {
let associative=matches!(self.tabs[i].scene.document.get_entity(handle),Some(acadrust::EntityType::Hatch(h)) if h.is_associative);
if associative {
self.command_line.push_info("HATCHEDIT: hatch is already associative.");
self.tabs[i].active_cmd=None;
}else{
let plane=match self.tabs[i].scene.document.get_entity(handle) {
Some(acadrust::EntityType::Hatch(h))=>{
let storage=crate::entities::curve::ocs_plane(h.normal,h.elevation);
crate::command::WorkingPlane::new(glam::DVec3::from_array(storage.origin),glam::DVec3::from_array(storage.x_axis),glam::DVec3::from_array(storage.y_axis))
},
_=>self.tabs[i].ucs_xform().working_plane(),
};
let mut sources=self.tabs[i].scene.boundary_sources_on_plane(plane,1e-6);
sources.remove(&handle);
let command=crate::modules::draw::draw::hatchedit::HatcheditCommand::for_association(handle,name,scale,angle,plane,sources);
self.tabs[i].scene.deselect_all();
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd=Some(Box::new(command));
}
return Task::none();
}
if let HatchEditOperation::DrawOrderBoundary{above}=&operation {
let references:Vec<_>=match self.tabs[i].scene.document.get_entity(handle) {
Some(acadrust::EntityType::Hatch(h))=>h.paths.iter().flat_map(|p|p.boundary_handles.iter())
.filter(|h|self.tabs[i].scene.document.get_entity(**h).is_some()).map(|h|format!("{:X}",h.value())).collect(),
_=>Vec::new(),
};
self.tabs[i].active_cmd=None;
if references.is_empty(){self.command_line.push_info("HATCHEDIT: no associated boundary objects.");return Task::none();}
self.tabs[i].scene.deselect_all();self.tabs[i].scene.select_entity(handle,false);
let command=format!("DRAWORDER {} {}",if *above{"ABOVE"}else{"UNDER"},references.join(" "));
return self.dispatch_view(&command,i).unwrap_or_else(Task::none);
}
if matches!(
&operation,
HatchEditOperation::DrawOrderFront | HatchEditOperation::DrawOrderBack
) {
let command = if matches!(&operation, HatchEditOperation::DrawOrderFront) {
"DRAWORDER FRONT"
} else {
"DRAWORDER BACK"
};
self.tabs[i].scene.deselect_all();
self.tabs[i].scene.select_entity(handle, false);
self.tabs[i].active_cmd = None;
return self.dispatch_view(command, i).unwrap_or_else(Task::none);
}
self.push_undo_snapshot(i, "HATCHEDIT");
match operation {
HatchEditOperation::Appearance { color, layer, transparency } => {
let layer=layer.map(|name|if name=="."{self.tabs[i].active_layer.clone()}else{name});
if layer.as_ref().is_some_and(|name|self.tabs[i].scene.document.layers.get(name).is_none()) {
self.discard_last_undo_entry(i);
self.command_line.push_error("HATCHEDIT: layer not found.");
return Task::none();
}
if let Some(entity)=self.tabs[i].scene.document.get_entity_mut(handle) {
let common=entity.common_mut();
if let Some(value)=color {common.color=value;common.color_name=None;common.color_book_handle=None;}
if let Some(value)=layer {common.layer=value;}
if let Some(value)=transparency {common.transparency=value;}
}
self.tabs[i].scene.bump_entities(&[(handle,crate::scene::ChangeKind::Modified)]);
self.refresh_properties();
}
HatchEditOperation::Update {
origin,
store_origin,
disassociate,
style,
annotative,
} => {
if store_origin {
if let Some((x, y)) = origin {
if !self.tabs[i].scene.document.set_hatch_origin([x, y]) {
self.discard_last_undo_entry(i);
self.command_line.push_error("HATCHEDIT: cannot store default origin.");
return Task::none();
}
}
}
if let Some(acadrust::EntityType::Hatch(hatch)) =
self.tabs[i].scene.document.get_entity_mut(handle)
{
if !name.is_empty() && name != hatch.pattern.name {
if let Some(entry) =
crate::scene::model::hatch_patterns::find(&name)
{
let mut pattern = crate::scene::model::hatch_patterns::build_dxf_pattern(entry);
crate::entities::hatch::scale_pattern_geometry(
&mut pattern,
scale.max(1.0e-6) as f64,
);
crate::entities::hatch::rotate_pattern_geometry(
&mut pattern,
(angle as f64).to_radians(),
);
let origin = hatch.pattern_origin();
crate::entities::hatch::translate_pattern_geometry(&mut pattern, origin.x, origin.y);
hatch.pattern = pattern;
hatch.is_solid = matches!(
entry.gpu,
crate::scene::model::hatch_model::HatchPattern::Solid
);
hatch.pattern_type =
acadrust::entities::HatchPatternType::Predefined;
hatch.gradient_color.enabled = false;
}
} else {
let requested_scale = scale.max(1.0e-6) as f64;
if hatch.pattern_scale > 1.0e-12 {
let factor = requested_scale / hatch.pattern_scale;
hatch.scale_pattern_about_origin(factor);
}
let requested_angle = (angle as f64).to_radians();
let delta = requested_angle - hatch.pattern_angle;
hatch.rotate_pattern_about_origin(delta);
}
hatch.pattern_scale = scale.max(1.0e-6) as f64;
hatch.pattern_angle = (angle as f64).to_radians();
if let Some((x, y)) = origin {
hatch.set_pattern_origin(acadrust::types::Vector2::new(x, y));
}
if disassociate {
for path in &mut hatch.paths {
path.boundary_handles.clear();
path.flags.set_external(false);
}
hatch.is_associative = false;
}
if let Some(style) = style {
hatch.style = style;
}
}
if let Some(value) = annotative {
crate::scene::annotative::set_entity_annotative(
&mut self.tabs[i].scene.document,
handle,
value,
);
if value {
if let Some(scale_handle) =
self.tabs[i].scene.creation_annotation_scale_handle()
{
crate::scene::annotative::create_annotation_context(
&mut self.tabs[i].scene.document,
handle,
scale_handle,
);
}
}
}
self.tabs[i].scene.bump_entities(&[(
handle,
crate::scene::ChangeKind::Modified,
)]);
}
HatchEditOperation::AddBoundaries(handles) => {
self.tabs[i]
.scene
.edit_hatch_boundary_handles(handle, &handles, true);
}
HatchEditOperation::RemoveBoundaries(handles) => {
self.tabs[i]
.scene
.edit_hatch_boundary_handles(handle, &handles, false);
}
HatchEditOperation::AssociatePaths(paths) => {
if paths.is_empty()||paths.iter().any(|path|path.boundary_handles.is_empty()||path.boundary_handles.iter().any(|source|self.tabs[i].scene.document.get_entity(*source).is_none())) {
self.discard_last_undo_entry(i);
self.command_line.push_error("HATCHEDIT: associated boundary is no longer available.");
return Task::none();
}
self.tabs[i].scene.replace_hatch_association(handle,paths);
}
HatchEditOperation::RecreateBoundary { associate, region } => {
let source = self.tabs[i].scene.document.get_entity(handle).cloned();
if let Some(acadrust::EntityType::Hatch(source)) = source {
let storage = crate::entities::curve::ocs_plane(
source.normal,
source.elevation,
);
let plane = crate::command::WorkingPlane::new(
glam::DVec3::from_array(storage.origin),
glam::DVec3::from_array(storage.x_axis),
glam::DVec3::from_array(storage.y_axis),
);
let (entities, path_groups) = if region {
let loops = source.paths.iter().map(|path| {
path.edges.iter().map(crate::entities::hatch::edge_curve).collect::<Option<Vec<_>>>()
}).collect::<Option<Vec<_>>>();
let Some(loops) = loops.filter(|loops| !loops.is_empty() && loops.iter().all(|ring| !ring.is_empty())) else {
self.discard_last_undo_entry(i);
self.command_line.push_error("HATCHEDIT: boundary cannot form a region.");
return Task::none();
};
let contained = loops.iter().enumerate().map(|(inner, ring)| {
let seed = ring[0].point_at(0.0);
loops.iter().enumerate().map(|(outer, boundary)| {
inner != outer && cadkernel::geom2d::contains(boundary, seed, cadkernel::geom2d::Tolerance::new(1e-6))
}).collect::<Vec<_>>()
}).collect::<Vec<_>>();
let depths = contained.iter().map(|row| row.iter().filter(|inside| **inside).count()).collect::<Vec<_>>();
let groups = depths.iter().enumerate().filter(|(_, depth)| **depth % 2 == 0).map(|(outer, depth)| {
let mut indices = vec![outer];
indices.extend((0..loops.len()).filter(|inner| depths[*inner] == *depth + 1 && contained[*inner][outer]));
indices
}).collect::<Vec<_>>();
let entities = groups.iter().map(|indices| {
let profiles = indices.iter().map(|index| loops[*index].clone()).collect::<Vec<_>>();
crate::scene::model::presspull_model::region_from_loops(&profiles, plane)
}).collect::<Option<Vec<_>>>();
let Some(entities) = entities else {
self.discard_last_undo_entry(i);
self.command_line.push_error("HATCHEDIT: boundary cannot form a region.");
return Task::none();
};
(entities, groups)
} else {
let rings = crate::scene::hatch_boundary_rings(&source);
if rings.len() != source.paths.len() {
self.discard_last_undo_entry(i);
self.command_line.push_error(
"HATCHEDIT: boundary cannot form a polyline.",
);
return Task::none();
}
let entities = crate::scene::boundary_entities(&rings, plane);
if entities.len() != source.paths.len() {
self.discard_last_undo_entry(i);
self.command_line.push_error(
"HATCHEDIT: boundary cannot form a polyline.",
);
return Task::none();
}
let groups = (0..entities.len()).map(|index| vec![index]).collect();
(entities, groups)
};
let mut path_handles = vec![None; source.paths.len()];
for (entity, paths) in entities.into_iter().zip(path_groups) {
if let Some(boundary) = self.commit_entity_handle(entity) {
for path in paths { if let Some(slot) = path_handles.get_mut(path) { *slot = Some(boundary); } }
}
}
if associate { if let Some(acadrust::EntityType::Hatch(hatch)) =
self.tabs[i].scene.document.get_entity_mut(handle)
{
for (path, boundary) in hatch.paths.iter_mut().zip(path_handles.iter().copied()) {
if let Some(boundary) = boundary {
path.boundary_handles = vec![boundary];
path.flags.set_external(true);
}
}
hatch.is_associative = path_handles.iter().all(Option::is_some);
} }
self.tabs[i].scene.bump_entities(&[(
handle,
crate::scene::ChangeKind::Modified,
)]);
}
}
HatchEditOperation::Separate => {
let source = self.tabs[i].scene.document.get_entity(handle).cloned();
if let Some(acadrust::EntityType::Hatch(hatch)) = source {
let groups = crate::scene::separated_hatch_path_groups(&hatch);
if groups.len() > 1 {
for paths in groups {
let mut separated = hatch.clone();
separated.common.handle = acadrust::Handle::NULL;
separated.paths = paths;
separated.is_associative = separated
.paths
.iter()
.any(|path| !path.boundary_handles.is_empty());
self.tabs[i]
.scene
.add_entity(acadrust::EntityType::Hatch(separated));
}
self.tabs[i].scene.erase_entities(&[handle]);
}
}
}
HatchEditOperation::DrawOrderFront
| HatchEditOperation::DrawOrderBack
| HatchEditOperation::DrawOrderBoundary {..}
| HatchEditOperation::BeginAssociate => unreachable!(),
}
self.tabs[i].dirty = true;
self.command_line
.push_output(crate::t!("HATCHEDIT: hatch updated.").as_ref());
}
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,
template,
} => {
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.open_mtext_editor(pos, handle, &initial, height, template.map(|m| *m));
}
CmdResult::SuspendForMTextInput {
pos,
initial,
height,
} => {
self.tabs[i].suspended_cmd = self.tabs[i].active_cmd.take();
self.tabs[i].snap_result = None;
self.tabs[i].scene.clear_preview_wire();
self.restore_pre_cmd_tangent();
self.command_mtext_input = true;
self.pending_command_editor_text = None;
self.open_mtext_editor(pos, None, &initial, height, None);
}
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,
None,
);
}
CmdResult::SuspendForTextInput { pos, entity } => {
self.tabs[i].suspended_cmd = self.tabs[i].active_cmd.take();
self.tabs[i].snap_result = None;
self.tabs[i].scene.clear_preview_wire();
self.restore_pre_cmd_tangent();
let height = entity.height;
self.open_text_inline(
pos,
None,
"",
height,
super::text_inline::TextEntityField::Text,
Some(entity),
);
}
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(crate::t!("TEXTEDIT: selected entity is not text.").as_ref());
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;
if self.tabs[i].active_cmd.as_ref().is_some_and(|command| command.name() == "PEDIT") {
let entities: Vec<_> = self.tabs[i].scene.document.entities().cloned().collect();
if let Some(command) = self.tabs[i].active_cmd.as_mut() {
for entity in entities { command.inject_picked_entity(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::SetTexteditMode(val) => {
self.texteditmode = val;
let display_val = if val { 1 } else { 0 };
self.command_line
.push_output(crate::tf!("TEXTEDITMODE set to {display_val}").as_ref());
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.tabs[i].scene.clear_preview_wire();
}
CmdResult::EditDimensions {
mut handles,
operation,
} => {
handles.retain(|handle| {
!self.tabs[i].scene.is_layer_locked(*handle)
&& matches!(
self.tabs[i].scene.document.get_entity(*handle),
Some(acadrust::EntityType::Dimension(_))
)
});
if handles.is_empty() {
self.command_line
.push_error(crate::t!("DIMEDIT: no editable dimensions were selected.").as_ref());
} else {
self.push_undo_snapshot(i, "DIMEDIT");
for handle in &handles {
if let Some(acadrust::EntityType::Dimension(dimension)) =
self.tabs[i].scene.document.get_entity_mut(*handle)
{
use crate::command::DimensionEditOperation as Operation;
match &operation {
Operation::Home => {
let base = dimension.base_mut();
base.text_middle_point = acadrust::types::Vector3::ZERO;
base.insertion_point = acadrust::types::Vector3::ZERO;
base.text_user_positioned = false;
base.text_rotation = 0.0;
}
Operation::NewText(text) => {
dimension.base_mut().set_text_override(
(!text.is_empty()).then_some(text.clone()),
);
}
Operation::Rotate(degrees) => {
dimension.base_mut().text_rotation = degrees.to_radians();
}
Operation::Oblique(degrees) => match dimension {
acadrust::entities::Dimension::Linear(value) => {
value.ext_line_rotation = degrees.to_radians();
}
acadrust::entities::Dimension::Aligned(value) => {
value.ext_line_rotation = degrees.to_radians();
}
_ => {}
},
}
}
self.tabs[i].scene.invalidate_dim_block_recorded(*handle);
}
let changes: Vec<_> = handles
.iter()
.copied()
.map(|handle| (handle, crate::scene::ChangeKind::Modified))
.collect();
self.tabs[i].scene.bump_entities(&changes);
self.tabs[i].dirty = true;
self.command_line.push_output(
crate::tf!("DIMEDIT: updated {} dimension(s).", handles.len()).as_ref(),
);
}
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::DdeditEntity { handle, new_text } => {
if self.reject_locked_edit(i, handle) {
self.tabs[i].active_cmd = None;
return Task::none();
}
self.push_undo_snapshot(i, "DDEDIT");
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)
self.tabs[i].scene.invalidate_dim_block_recorded(handle);
}
if updated {
self.tabs[i].dirty = true;
self.command_line.push_output(crate::t!("DDEDIT: text updated.").as_ref());
} else {
self.discard_last_undo_entry(i);
self.command_line
.push_error(crate::t!("DDEDIT: entity type not supported.").as_ref());
}
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::ReturnPoint(_) => {
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.tabs[i].scene.clear_preview_wire();
self.restore_pre_cmd_tangent();
}
}
// When no command is running the ribbon tool button still has to
// visually deactivate. Keyboard focus is assigned below to whichever
// editor currently owns typed input.
if self.tabs[i].active_cmd.is_none() {
self.ribbon.deactivate_tool();
}
// The rich text canvas owns keyboard editing itself. Leaving the
// hidden command input focused would make it consume Left/Right before
// the editor can handle them.
if self.mtext_editor.is_some() {
return self.unfocus_widgets();
}
// 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_dependencies(
&mut self,
i: usize,
deps: &crate::app::ClipboardDeps,
) {
use acadrust::TableEntry;
if deps.is_empty() {
return;
}
let doc = &mut self.tabs[i].scene.document;
for rec in &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 &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 &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 &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);
}
}
}
pub(super) fn merge_clipboard_deps(&mut self, i: usize) {
let deps = self.clipboard_deps.clone();
self.merge_dependencies(i, &deps);
}
/// 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();
let annotation_delta = match translate {
Some(crate::command::EntityTransform::Translate(delta)) => delta,
_ => glam::DVec3::ZERO,
};
self.merge_clipboard_ext_objects(i, &by_index, annotation_delta);
// Source handles stored in the clipboard map one-to-one to the freshly
// pasted handles. Use that map to reconnect LEADER -> copied annotation.
let mut handle_map = rustc_hash::FxHashMap::default();
for (source, &copied) in self.clipboard.iter().zip(by_index.iter()) {
if !copied.is_null() {
handle_map.insert(source.common().handle, copied);
}
}
let leader_links: Vec<(Handle, Handle)> = self
.clipboard
.iter()
.filter_map(|source| {
let acadrust::EntityType::Leader(leader) = source else {
return None;
};
let copied_leader = handle_map.get(&source.common().handle).copied()?;
let copied_annotation = handle_map
.get(&leader.annotation_handle)
.copied()
.unwrap_or(Handle::NULL);
Some((copied_leader, copied_annotation))
})
.collect();
for (leader_handle, annotation_handle) in leader_links {
if let Some(acadrust::EntityType::Leader(leader)) =
self.tabs[i].scene.document.get_entity_mut(leader_handle)
{
leader.annotation_handle = annotation_handle;
}
let _ = self.tabs[i]
.scene
.sync_displayed_annotation_context(leader_handle);
}
// Recreate any group whose whole membership was copied, so a pasted
// group stays grouped — cross-drawing too, since the groups were
// snapshotted into the clipboard at copy time. `by_index` is aligned
// with `self.clipboard`, so the source handle of each pasted entity maps
// its clipboard clone to its new handle. Same shared `recreate_groups`
// the in-drawing COPY path uses. (#440)
if !self.clipboard_deps.groups.is_empty() {
let groups = self.clipboard_deps.groups.clone();
self.tabs[i].scene.recreate_groups(groups, &handle_map);
}
// Constraints wholly within the pasted selection follow it.
self.tabs[i].scene.duplicate_sketch_constraints_for(&handle_map);
// 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],
annotation_delta: glam::DVec3,
) {
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);
}
crate::scene::annotative::translate_annotation_contexts(
doc,
new_entity,
annotation_delta,
);
}
}
// The wires were tessellated before the filters existed; refresh only
// the freshly-pasted entities whose clip object graph was attached.
let changes: Vec<_> = by_index
.iter()
.copied()
.filter(|handle| !handle.is_null())
.map(|handle| (handle, crate::scene::ChangeKind::Modified))
.collect();
self.tabs[i].scene.bump_entities(&changes);
}
/// 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, scale) in &cap.annotation_scales {
let target = crate::scene::annotative::ensure_scale_object(doc, scale);
remap.insert(*old, target);
}
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()
}
/// Replace references to a clipboard entity inside one recreated extension
/// dictionary graph after its final block-owned handle becomes known.
pub(crate) fn remap_ext_subtree_reference(
doc: &mut acadrust::CadDocument,
root: Handle,
source_entity: Handle,
target_entity: Handle,
) {
use acadrust::objects::ObjectType;
use rustc_hash::FxHashSet;
use std::collections::HashMap;
let remap = HashMap::from([(source_entity, target_entity)]);
let mut seen = FxHashSet::default();
let mut pending = vec![root];
while let Some(handle) = pending.pop() {
if handle.is_null() || !seen.insert(handle) {
continue;
}
let children = match doc.objects.get(&handle) {
Some(ObjectType::Dictionary(dictionary)) => {
let mut children: Vec<_> =
dictionary.entries.iter().map(|(_, child)| *child).collect();
if let Some(extension) = dictionary.xdictionary_handle {
children.push(extension);
}
children
}
Some(ObjectType::DictionaryWithDefault(dictionary)) => {
let mut children: Vec<_> =
dictionary.entries.iter().map(|(_, child)| *child).collect();
children.push(dictionary.default_handle);
children
}
_ => Vec::new(),
};
pending.extend(children);
if let Some(mut object) = doc.objects.remove(&handle) {
remap_object(&mut object, handle, &remap);
doc.objects.insert(handle, object);
}
}
}
/// 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);
for entry in &mut x.entries {
if let acadrust::objects::XRecordValue::Handle(handle) = &mut entry.value {
*handle = map(*handle);
}
}
}
ObjectType::Group(g) => {
g.handle = new_handle;
g.owner = map(g.owner);
for h in g.entities.iter_mut() {
*h = map(*h);
}
}
ObjectType::ObjectContextData(context) => {
context.handle = new_handle;
context.owner_handle = map(context.owner_handle);
for reactor in &mut context.reactors {
*reactor = map(*reactor);
}
if let Some(dictionary) = &mut context.xdictionary_handle {
*dictionary = map(*dictionary);
}
context.scale = map(context.scale);
match &mut context.kind {
acadrust::objects::ObjectContextKind::Dim(dimension) => {
dimension.block = map(dimension.block);
}
acadrust::objects::ObjectContextKind::HatchView(hatch) => {
hatch.view = map(hatch.view);
}
acadrust::objects::ObjectContextKind::MTextAttribute(attribute) => {
if let Some(embedded) = &mut attribute.context {
embedded.owner_handle = map(embedded.owner_handle);
for reactor in &mut embedded.reactors {
*reactor = map(*reactor);
}
if let Some(dictionary) = &mut embedded.xdictionary_handle {
*dictionary = map(*dictionary);
}
embedded.scale = map(embedded.scale);
}
}
acadrust::objects::ObjectContextKind::MLeader(mleader) => {
if let Some(handle) = &mut mleader.text_style_handle {
*handle = map(*handle);
}
if let Some(handle) = &mut mleader.block_content_handle {
*handle = map(*handle);
}
if let Some(handle) = &mut mleader.scale_handle {
*handle = map(*handle);
}
for root in &mut mleader.leader_roots {
for line in &mut root.lines {
if let Some(handle) = &mut line.line_type_handle {
*handle = map(*handle);
}
if let Some(handle) = &mut line.arrowhead_handle {
*handle = map(*handle);
}
}
}
}
_ => {}
}
}
// 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 ───────────────────────────────────────────────────────────
struct DimBreakResult {
changed: Vec<Handle>,
message: String,
}
fn wire_segments(
models: &[crate::scene::model::wire_model::WireModel],
) -> Vec<(glam::DVec3, glam::DVec3)> {
let mut output = Vec::new();
for model in models {
for run in model.points.split(|point| !point[0].is_finite()) {
for pair in run.windows(2) {
let first = glam::DVec3::new(
pair[0][0] as f64,
pair[0][1] as f64,
pair[0][2] as f64,
);
let second = glam::DVec3::new(
pair[1][0] as f64,
pair[1][1] as f64,
pair[1][2] as f64,
);
if first.is_finite()
&& second.is_finite()
&& first.distance_squared(second) > 1.0e-18
{
output.push((first, second));
}
}
}
}
output
}
fn segment_intersection_xy(
first: (glam::DVec3, glam::DVec3),
second: (glam::DVec3, glam::DVec3),
) -> Option<(glam::DVec3, glam::DVec3)> {
let gap = cadkernel::space::segment_break_gap_xy(
[first.0.to_array(), first.1.to_array()],
[second.0.to_array(), second.1.to_array()],
0.25,
)?;
Some((glam::DVec3::from_array(gap[0]), glam::DVec3::from_array(gap[1])))
}
fn break_object_handle(document: &acadrust::CadDocument, dimension: Handle) -> Option<Handle> {
document.objects.iter().find_map(|(handle, object)| {
let acadrust::objects::ObjectType::DataObject(object) = object else {
return None;
};
let acadrust::objects::DataObjectData::BreakData(data) = &object.data else {
return None;
};
(data.dimension_reference == dimension).then_some(*handle)
})
}
fn remove_dimension_break_data(document: &mut acadrust::CadDocument, dimension: Handle) -> bool {
let handles: Vec<_> = document
.objects
.iter()
.filter_map(|(handle, object)| match object {
acadrust::objects::ObjectType::DataObject(object) => match &object.data {
acadrust::objects::DataObjectData::BreakData(data)
if data.dimension_reference == dimension => Some(*handle),
_ => None,
},
_ => None,
})
.collect();
if handles.is_empty() {
return false;
}
for handle in &handles {
document.objects.remove(handle);
}
if let Some(dictionary_handle) = document.extension_dictionary_handle(dimension) {
if let Some(acadrust::objects::ObjectType::Dictionary(dictionary)) =
document.objects.get_mut(&dictionary_handle)
{
dictionary.entries.retain(|(name, handle)| {
!name.eq_ignore_ascii_case("ACAD_BREAKDATA") && !handles.contains(handle)
});
dictionary
.hard_owner_entries
.retain(|name| !name.eq_ignore_ascii_case("ACAD_BREAKDATA"));
}
}
true
}
fn write_dimension_break_data(
document: &mut acadrust::CadDocument,
dimension: Handle,
reserved_reference: Handle,
references: Vec<acadrust::objects::BreakPointReference>,
) -> bool {
use acadrust::objects::{BreakData, DataObject, DataObjectData, Dictionary, ObjectType};
if references.is_empty() {
return remove_dimension_break_data(document, dimension);
}
if let Some(handle) = break_object_handle(document, dimension) {
if let Some(ObjectType::DataObject(object)) = document.objects.get_mut(&handle) {
object.data = DataObjectData::BreakData(BreakData {
version: 0,
dimension_reference: dimension,
reserved_reference,
point_references: references,
});
return true;
}
}
let dictionary_handle = match document.extension_dictionary_handle(dimension) {
Some(handle) => handle,
None => {
let handle = document.allocate_handle();
let mut dictionary = Dictionary::new();
dictionary.handle = handle;
dictionary.owner = dimension;
dictionary.hard_owner = true;
document
.objects
.insert(handle, ObjectType::Dictionary(dictionary));
if let Some(entity) = document.get_entity_mut(dimension) {
entity.common_mut().xdictionary_handle = Some(handle);
}
handle
}
};
let object_handle = document.allocate_handle();
let mut object = DataObject::new(DataObjectData::BreakData(BreakData {
version: 0,
dimension_reference: dimension,
reserved_reference,
point_references: references,
}));
object.handle = object_handle;
object.owner = dictionary_handle;
document
.objects
.insert(object_handle, ObjectType::DataObject(object));
if let Some(ObjectType::Dictionary(dictionary)) =
document.objects.get_mut(&dictionary_handle)
{
dictionary
.entries
.retain(|(name, _)| !name.eq_ignore_ascii_case("ACAD_BREAKDATA"));
dictionary.add_entry("ACAD_BREAKDATA", object_handle);
dictionary.set_entry_hard_owner("ACAD_BREAKDATA", true);
}
true
}
fn break_reference(
identifier: i32,
reference_type: i32,
first: glam::DVec3,
second: glam::DVec3,
) -> acadrust::objects::BreakPointReference {
acadrust::objects::BreakPointReference {
version: 0,
reserved: 0,
reference_type,
flags: 0,
identifier,
first_point: acadrust::types::Vector3::new(first.x, first.y, first.z),
second_point: acadrust::types::Vector3::new(second.x, second.y, second.z),
trailing_version: 0,
}
}
fn apply_dimbreak(
scene: &mut crate::scene::Scene,
requested: &[Handle],
operation: crate::command::DimensionBreakOperation,
) -> DimBreakResult {
use crate::command::DimensionBreakOperation;
let dimensions: Vec<_> = requested
.iter()
.copied()
.filter(|handle| {
matches!(
scene.document.get_entity(*handle),
Some(acadrust::EntityType::Dimension(_))
) && scene.locked_layer_name(*handle).is_none()
})
.collect();
if dimensions.is_empty() {
return DimBreakResult {
changed: Vec::new(),
message: crate::t!("DIMBREAK: no editable dimensions were selected.").into_owned(),
};
}
if matches!(operation, DimensionBreakOperation::Remove) {
let changed: Vec<_> = dimensions
.into_iter()
.filter(|handle| remove_dimension_break_data(&mut scene.document, *handle))
.collect();
return DimBreakResult {
message: crate::tf!("DIMBREAK: removed breaks from {} dimension(s).", changed.len())
.into_owned(),
changed,
};
}
let crossing_handles: Vec<Handle> = match operation {
DimensionBreakOperation::Object(handle) => vec![handle],
DimensionBreakOperation::Auto => scene
.document
.entities()
.map(|entity| entity.common().handle)
.filter(|handle| {
!dimensions.contains(handle) && scene.entity_belongs_to_active_space(*handle)
})
.collect(),
DimensionBreakOperation::Manual(_, _) | DimensionBreakOperation::Remove => Vec::new(),
};
let crossing_segments: Vec<_> = crossing_handles
.iter()
.flat_map(|handle| wire_segments(&scene.wire_models_for(&[*handle])))
.collect();
let mut pending = Vec::new();
for dimension in &dimensions {
let existing: Vec<_> = break_object_handle(&scene.document, *dimension)
.and_then(|handle| scene.document.objects.get(&handle))
.and_then(|object| match object {
acadrust::objects::ObjectType::DataObject(object) => match &object.data {
acadrust::objects::DataObjectData::BreakData(data) => {
Some(data.point_references.clone())
}
_ => None,
},
_ => None,
})
.unwrap_or_default();
let mut references = if matches!(operation, DimensionBreakOperation::Manual(_, _)) {
existing
} else {
existing
.into_iter()
.filter(|reference| reference.reference_type == 2)
.collect()
};
match operation {
DimensionBreakOperation::Manual(first, second) => {
references.push(break_reference(references.len() as i32, 2, first, second));
}
DimensionBreakOperation::Auto | DimensionBreakOperation::Object(_) => {
let dimension_segments = wire_segments(&scene.wire_models_for(&[*dimension]));
let mut intersections = Vec::new();
for dim_segment in &dimension_segments {
for crossing_segment in &crossing_segments {
if let Some(points) =
segment_intersection_xy(*dim_segment, *crossing_segment)
{
let center = (points.0 + points.1) * 0.5;
if intersections.iter().all(
|(first, second): &(glam::DVec3, glam::DVec3)| {
center.distance_squared((*first + *second) * 0.5) > 1.0e-8
},
) {
intersections.push(points);
}
}
}
}
let start = references.len() as i32;
references.extend(intersections.into_iter().enumerate().map(
|(index, points)| {
break_reference(start + index as i32, 1, points.0, points.1)
},
));
}
DimensionBreakOperation::Remove => {}
}
pending.push((*dimension, references));
}
let reserved = match operation {
DimensionBreakOperation::Object(handle) => handle,
_ => Handle::NULL,
};
let changed: Vec<_> = pending
.into_iter()
.filter_map(|(dimension, references)| {
write_dimension_break_data(&mut scene.document, dimension, reserved, references)
.then_some(dimension)
})
.collect();
DimBreakResult {
message: if changed.is_empty() {
crate::t!("DIMBREAK: no intersections were found.").into_owned()
} else {
crate::tf!("DIMBREAK: updated {} dimension(s).", changed.len()).into_owned()
},
changed,
}
}
fn apply_dimspace(
scene: &mut crate::scene::Scene,
base_h: Handle,
others: &[Handle],
requested_spacing: Option<f64>,
) -> bool {
use acadrust::entities::Dimension;
let (axis, normal, definition, auto_spacing) = match scene.document.get_entity(base_h) {
Some(acadrust::EntityType::Dimension(dimension @ Dimension::Linear(d))) => {
let spacing = dimension_auto_spacing(&scene.document, dimension, scene.creation_annotation_multiplier());
(
[d.rotation.cos(), d.rotation.sin(), 0.0],
[d.base.normal.x, d.base.normal.y, d.base.normal.z],
[d.definition_point.x, d.definition_point.y, d.definition_point.z],
spacing,
)
}
Some(acadrust::EntityType::Dimension(dimension @ Dimension::Aligned(d))) => {
let spacing = dimension_auto_spacing(&scene.document, dimension, scene.creation_annotation_multiplier());
(
[
d.second_point.x - d.first_point.x,
d.second_point.y - d.first_point.y,
d.second_point.z - d.first_point.z,
],
[d.base.normal.x, d.base.normal.y, d.base.normal.z],
[d.definition_point.x, d.definition_point.y, d.definition_point.z],
spacing,
)
}
_ => return false,
};
let Some(frame) = cadkernel::space::dimension_spacing_frame(axis, normal, definition) else {
return false;
};
let effective_spacing = requested_spacing.unwrap_or(auto_spacing);
let mut changes = Vec::new();
for (idx, &h) in others.iter().enumerate() {
let target = frame.coordinate + effective_spacing * (idx + 1) as f64;
let mut changed = false;
if let Some(acadrust::EntityType::Dimension(dim)) = scene.document.get_entity_mut(h) {
let slide = |p: &mut acadrust::types::Vector3| {
let point = cadkernel::space::move_to_dimension_spacing(
[p.x, p.y, p.z],
frame,
target,
);
*p = acadrust::types::Vector3::new(point[0], point[1], point[2]);
};
match dim {
Dimension::Linear(d) => {
slide(&mut d.definition_point);
d.base.definition_point = d.definition_point;
changed = true;
}
Dimension::Aligned(d) => {
slide(&mut d.definition_point);
d.base.definition_point = d.definition_point;
changed = true;
}
_ => {}
}
}
if changed {
// The dimension line moved, so its baked *D block is stale — drop it
// so the next save re-bakes it. (#181)
scene.invalidate_dim_block_recorded(h);
changes.push((h, crate::scene::ChangeKind::Modified));
}
}
if !changes.is_empty() {
scene.bump_entities(&changes);
}
!changes.is_empty()
}
fn dimension_auto_spacing(
document: &acadrust::CadDocument,
dimension: &acadrust::entities::Dimension,
annotation_multiplier: f64,
) -> f64 {
let style = document
.dim_styles
.iter()
.find(|style| style.name.eq_ignore_ascii_case(&dimension.base().style_name))
.map(|style| crate::entities::dimension::resolved_dimension_style(style, dimension, document));
style
.map(|style| {
let scale = if style.dimscale > 1.0e-9 {
style.dimscale
} else {
annotation_multiplier.max(1.0e-9)
};
(style.dimdli.abs() * scale).max(1.0e-6)
})
.unwrap_or(3.75)
}
fn apply_mleader_align(
scene: &mut crate::scene::Scene,
handles: &[acadrust::Handle],
from: glam::DVec3,
to: glam::DVec3,
) -> bool {
use cadkernel::geom2d::{closest_point, Curve, Vec2, XLine};
let Some(direction) = Vec2::new(to.x - from.x, to.y - from.y).normalize() else {
return false;
};
let line = Curve::XLine(XLine {
base: [from.x, from.y],
direction: direction.into(),
});
let mut changed = Vec::new();
for &handle in handles {
if let Some(acadrust::EntityType::MultiLeader(ml)) =
scene.document.get_entity_mut(handle)
{
let old = ml.context.content_base_point;
let projected = closest_point(&line, [old.x, old.y]).point;
let new_x = projected[0];
let new_y = projected[1];
let shift_x = new_x - old.x;
let shift_y = new_y - old.y;
if shift_x.abs() <= 1.0e-12 && shift_y.abs() <= 1.0e-12 {
continue;
}
ml.context.content_base_point.x = new_x;
ml.context.content_base_point.y = new_y;
ml.context.text_location.x += shift_x;
ml.context.text_location.y += shift_y;
ml.context.block_content_location.x += shift_x;
ml.context.block_content_location.y += shift_y;
for root in &mut ml.context.leader_roots {
root.connection_point.x += shift_x;
root.connection_point.y += shift_y;
}
changed.push((handle, crate::scene::ChangeKind::Modified));
}
}
if !changed.is_empty() {
scene.bump_entities(&changed);
}
!changed.is_empty()
}
fn compatible_mleader_collect_handles(
scene: &crate::scene::Scene,
handles: &[acadrust::Handle],
) -> Vec<acadrust::Handle> {
let Some((base_block, base_style)) = handles.iter().find_map(|handle| {
if scene.is_layer_locked(*handle) {
return None;
}
let acadrust::EntityType::MultiLeader(leader) =
scene.document.get_entity(*handle)?
else {
return None;
};
(leader.content_type == acadrust::entities::LeaderContentType::Block)
.then_some((leader.block_content_handle, leader.style_handle))
}) else {
return Vec::new();
};
let mut compatible = Vec::new();
for handle in handles.iter().copied() {
if compatible.contains(&handle) {
continue;
}
if !scene.is_layer_locked(handle)
&& matches!(
scene.document.get_entity(handle),
Some(acadrust::EntityType::MultiLeader(leader))
if leader.content_type == acadrust::entities::LeaderContentType::Block
&& leader.block_content_handle == base_block
&& leader.style_handle == base_style
)
{
compatible.push(handle);
}
}
compatible
}
fn apply_mleader_collect(
scene: &mut crate::scene::Scene,
handles: &[acadrust::Handle],
point: glam::DVec3,
) -> bool {
if handles.len() < 2 {
return false;
}
let px = point.x;
let py = point.y;
let Some(acadrust::EntityType::MultiLeader(base)) =
scene.document.get_entity(handles[0])
else {
return false;
};
let base_block = base.block_content_handle;
let base_style = base.style_handle;
let mut extra_roots: Vec<acadrust::entities::LeaderRoot> = Vec::new();
let mut merged_handles = Vec::new();
for &h in &handles[1..] {
if let Some(acadrust::EntityType::MultiLeader(ml)) = scene.document.get_entity(h) {
if ml.content_type == acadrust::entities::LeaderContentType::Block
&& ml.block_content_handle == base_block
&& ml.style_handle == base_style
{
let shift_x = px - ml.context.content_base_point.x;
let shift_y = py - ml.context.content_base_point.y;
extra_roots.extend(ml.context.leader_roots.iter().cloned().map(|mut root| {
root.connection_point.x += shift_x;
root.connection_point.y += shift_y;
root
}));
merged_handles.push(h);
}
}
}
if merged_handles.is_empty() {
return false;
}
if let Some(acadrust::EntityType::MultiLeader(ml)) = scene.document.get_entity_mut(handles[0]) {
let shift_x = px - ml.context.content_base_point.x;
let shift_y = py - ml.context.content_base_point.y;
for root in &mut ml.context.leader_roots {
root.connection_point.x += shift_x;
root.connection_point.y += shift_y;
}
ml.context.content_base_point.x = px;
ml.context.content_base_point.y = py;
ml.context.block_content_location.x = px;
ml.context.block_content_location.y = py;
ml.context.text_location.x = px;
ml.context.text_location.y = py;
for root in extra_roots {
ml.context.leader_roots.push(root);
}
}
scene.erase_entities(&merged_handles);
scene.bump_entities(&[(handles[0], crate::scene::ChangeKind::Modified)]);
true
}
/// 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,
Some(t.width_factor),
Some(t.oblique_angle),
)),
E::MText(m) => Some((m.style.clone(), m.height, None, None)),
E::AttributeDefinition(a) => Some((
a.text_style.clone(),
a.height,
Some(a.width_factor),
Some(a.oblique_angle),
)),
E::AttributeEntity(a) => Some((
a.text_style.clone(),
a.height,
Some(a.width_factor),
Some(a.oblique_angle),
)),
_ => None,
};
if let Some((style, height, wf, ob)) = text_fmt {
match dst {
E::Text(t) => {
t.style = style.clone();
t.height = height;
if let Some(value) = wf {
t.width_factor = value;
}
if let Some(value) = ob {
t.oblique_angle = value;
}
}
E::MText(m) => {
m.style = style.clone();
m.height = height;
}
E::AttributeDefinition(a) => {
a.text_style = style.clone();
a.height = height;
if let Some(value) = wf {
a.width_factor = value;
}
if let Some(value) = ob {
a.oblique_angle = value;
}
}
E::AttributeEntity(a) => {
a.text_style = style;
a.height = height;
if let Some(value) = wf {
a.width_factor = value;
}
if let Some(value) = ob {
a.oblique_angle = value;
}
}
_ => {}
}
}
// 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.is_double = sh.is_double;
dh.style = sh.style;
dh.gradient_color = sh.gradient_color.clone();
}
// Polyline display style crosses lightweight and legacy 2D polylines.
// Per-vertex/tapered widths are resampled over the destination vertices;
// they must not be flattened into the source's constant-width field.
if let Some(style) = PolylineMatchStyle::from_entity(src) {
style.apply_to(dst);
}
if let (E::Leader(sl), E::Leader(dl)) = (src, dst as &mut E) {
dl.arrow_enabled = sl.arrow_enabled;
dl.path_type = sl.path_type;
dl.hookline_direction = sl.hookline_direction;
dl.hookline_enabled = sl.hookline_enabled;
dl.override_color = sl.override_color;
dl.dimension_gap = sl.dimension_gap;
dl.arrowhead_type = sl.arrowhead_type;
dl.arrow_size = sl.arrow_size;
dl.byblock_color = sl.byblock_color;
}
if let (E::Tolerance(st), E::Tolerance(dt)) = (src, dst as &mut E) {
dt.dimension_style_handle = st.dimension_style_handle;
dt.text_height = st.text_height;
dt.dimension_gap = st.dimension_gap;
}
// Paper-space geometry and view position stay; viewport display/plot
// styling and effective scale follow the source.
if let (E::Viewport(sv), E::Viewport(dv)) = (src, dst as &mut E) {
let scale = crate::scene::vp_effective_scale(sv.custom_scale, sv.view_height, sv.height);
dv.custom_scale = scale;
if scale.abs() > 1e-9 {
dv.view_height = dv.height / scale;
}
dv.status.locked = sv.status.locked;
dv.status.hide_plot = sv.status.hide_plot;
dv.render_mode = sv.render_mode;
dv.style_sheet = sv.style_sheet.clone();
dv.shade_plot_mode = sv.shade_plot_mode;
dv.background_handle = sv.background_handle;
dv.shade_plot_handle = sv.shade_plot_handle;
dv.visual_style_handle = sv.visual_style_handle;
dv.default_lighting = sv.default_lighting;
dv.default_lighting_type = sv.default_lighting_type;
dv.brightness = sv.brightness;
dv.contrast = sv.contrast;
dv.ambient_color = sv.ambient_color;
}
if let (E::Table(st), E::Table(dt)) = (src, dst as &mut E) {
dt.table_style_handle = st.table_style_handle;
dt.base_style = st.base_style.clone();
dt.override_flag = st.override_flag;
dt.override_border_color = st.override_border_color;
dt.override_border_line_weight = st.override_border_line_weight;
dt.override_border_visibility = st.override_border_visibility;
dt.legacy_style_override = st.legacy_style_override.clone();
dt.legacy_border_colors = st.legacy_border_colors.clone();
dt.legacy_border_line_weights = st.legacy_border_line_weights.clone();
dt.legacy_border_visibility = st.legacy_border_visibility.clone();
}
if let (E::MLine(sm), E::MLine(dm)) = (src, dst as &mut E) {
dm.style_handle = sm.style_handle;
dm.style_name = sm.style_name.clone();
dm.style_element_count = sm.style_element_count;
dm.justification = sm.justification;
dm.scale_factor = sm.scale_factor;
// Stored segment offsets bake the old style into each vertex. Empty
// data deliberately selects the renderer's style-derived fallback.
for vertex in &mut dm.vertices {
vertex.segments.clear();
}
}
// 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.text_left_attachment = sm.text_left_attachment;
dm.text_right_attachment = sm.text_right_attachment;
dm.text_top_attachment = sm.text_top_attachment;
dm.text_bottom_attachment = sm.text_bottom_attachment;
dm.text_attachment_direction = sm.text_attachment_direction;
dm.text_attachment_point = sm.text_attachment_point;
dm.text_alignment = sm.text_alignment;
dm.text_angle_type = sm.text_angle_type;
dm.text_direction_negative = sm.text_direction_negative;
dm.text_align_in_ipe = sm.text_align_in_ipe;
dm.block_content_color = sm.block_content_color;
dm.block_connection_type = sm.block_connection_type;
dm.block_scale = sm.block_scale;
dm.scale_factor = sm.scale_factor;
dm.property_override_flags = sm.property_override_flags;
dm.enable_annotation_scale = sm.enable_annotation_scale;
dm.extend_leader_to_text = sm.extend_leader_to_text;
dm.arrowhead_overrides = sm.arrowhead_overrides.clone();
let sc = &sm.context;
let dc = &mut dm.context;
dc.scale_factor = sc.scale_factor;
dc.text_height = sc.text_height;
dc.text_width = sc.text_width;
dc.text_boundary_height = sc.text_boundary_height;
dc.line_spacing_factor = sc.line_spacing_factor;
dc.line_spacing_style = sc.line_spacing_style;
dc.text_color = sc.text_color;
dc.text_attachment_point = sc.text_attachment_point;
dc.text_flow_direction = sc.text_flow_direction;
dc.text_alignment = sc.text_alignment;
dc.text_left_attachment = sc.text_left_attachment;
dc.text_right_attachment = sc.text_right_attachment;
dc.text_top_attachment = sc.text_top_attachment;
dc.text_bottom_attachment = sc.text_bottom_attachment;
dc.text_height_automatic = sc.text_height_automatic;
dc.word_break = sc.word_break;
dc.text_style_handle = sc.text_style_handle;
dc.block_content_scale = sc.block_content_scale;
dc.block_content_color = sc.block_content_color;
dc.block_connection_type = sc.block_connection_type;
dc.column_type = sc.column_type;
dc.column_width = sc.column_width;
dc.column_gutter = sc.column_gutter;
dc.column_flow_reversed = sc.column_flow_reversed;
dc.column_sizes = sc.column_sizes.clone();
dc.background_fill_enabled = sc.background_fill_enabled;
dc.background_mask_fill_on = sc.background_mask_fill_on;
dc.background_fill_color = sc.background_fill_color;
dc.background_scale_factor = sc.background_scale_factor;
dc.background_transparency = sc.background_transparency;
dc.arrowhead_size = sc.arrowhead_size;
dc.landing_gap = sc.landing_gap;
dc.scale_handle = sc.scale_handle;
}
// External-reference identity, placement and clip geometry stay. Only
// display controls/appearance are matched.
if let (E::RasterImage(si), E::RasterImage(di)) = (src, dst as &mut E) {
di.flags = si.flags;
di.clipping_enabled = si.clipping_enabled;
di.brightness = si.brightness;
di.contrast = si.contrast;
di.fade = si.fade;
di.clip_boundary.clip_mode = si.clip_boundary.clip_mode;
}
if let (E::Wipeout(sw), E::Wipeout(dw)) = (src, dst as &mut E) {
dw.flags = sw.flags;
dw.clipping_enabled = sw.clipping_enabled;
dw.brightness = sw.brightness;
dw.contrast = sw.contrast;
dw.fade = sw.fade;
dw.clip_mode = sw.clip_mode;
}
if let (E::Underlay(su), E::Underlay(du)) = (src, dst as &mut E) {
du.flags = su.flags;
du.contrast = su.contrast;
du.fade = su.fade;
du.clip_inverted = su.clip_inverted;
}
}
struct PolylineMatchStyle {
plinegen: bool,
widths: Vec<(f64, f64)>,
}
impl PolylineMatchStyle {
fn from_entity(entity: &acadrust::EntityType) -> Option<Self> {
use acadrust::EntityType as E;
match entity {
E::LwPolyline(poly) => {
let widths = if poly.vertices.is_empty() {
vec![(poly.constant_width, poly.constant_width)]
} else {
poly.vertices
.iter()
.map(|v| {
(
width_or_default(v.start_width, poly.constant_width),
width_or_default(v.end_width, poly.constant_width),
)
})
.collect()
};
Some(Self {
plinegen: poly.plinegen,
widths,
})
}
E::Polyline2D(poly) => {
let widths = if poly.vertices.is_empty() {
vec![(poly.start_width, poly.end_width)]
} else {
poly.vertices
.iter()
.map(|v| {
(
width_or_default(v.start_width, poly.start_width),
width_or_default(v.end_width, poly.end_width),
)
})
.collect()
};
Some(Self {
plinegen: poly.flags.bits()
& acadrust::entities::PolylineFlags::LINETYPE_CONTINUOUS.bits()
!= 0,
widths,
})
}
_ => None,
}
}
fn apply_to(&self, entity: &mut acadrust::EntityType) {
use acadrust::EntityType as E;
match entity {
E::LwPolyline(poly) => {
poly.plinegen = self.plinegen;
let sampled = resample_widths(&self.widths, poly.vertices.len());
let first = sampled.first().copied().unwrap_or((0.0, 0.0));
let can_use_constant = widths_are_same(&sampled) && nearly_equal(first.0, first.1);
poly.constant_width = if can_use_constant { first.0 } else { 0.0 };
for (vertex, (start, end)) in poly.vertices.iter_mut().zip(sampled) {
if can_use_constant {
vertex.start_width = 0.0;
vertex.end_width = 0.0;
} else {
vertex.start_width = start;
vertex.end_width = end;
}
}
}
E::Polyline2D(poly) => {
let mut bits = poly.flags.bits();
let flag = acadrust::entities::PolylineFlags::LINETYPE_CONTINUOUS.bits();
if self.plinegen {
bits |= flag;
} else {
bits &= !flag;
}
poly.flags = acadrust::entities::PolylineFlags::from_bits(bits);
let sampled = resample_widths(&self.widths, poly.vertices.len());
let first = sampled.first().copied().unwrap_or((0.0, 0.0));
let can_use_defaults = widths_are_same(&sampled);
if can_use_defaults {
poly.start_width = first.0;
poly.end_width = first.1;
} else {
poly.start_width = 0.0;
poly.end_width = 0.0;
}
for (vertex, (start, end)) in poly.vertices.iter_mut().zip(sampled) {
if can_use_defaults {
vertex.start_width = 0.0;
vertex.end_width = 0.0;
} else {
vertex.start_width = start;
vertex.end_width = end;
}
}
}
_ => {}
}
}
}
fn width_or_default(value: f64, default: f64) -> f64 {
if value.abs() <= 1e-12 {
default
} else {
value
}
}
fn nearly_equal(a: f64, b: f64) -> bool {
(a - b).abs() <= 1e-9
}
fn widths_are_same(widths: &[(f64, f64)]) -> bool {
let Some(first) = widths.first() else {
return true;
};
widths
.iter()
.all(|value| nearly_equal(value.0, first.0) && nearly_equal(value.1, first.1))
}
fn resample_widths(source: &[(f64, f64)], count: usize) -> Vec<(f64, f64)> {
if count == 0 || source.is_empty() {
return Vec::new();
}
if count == 1 || source.len() == 1 {
return vec![source[0]; count];
}
(0..count)
.map(|index| {
let source_index = index.saturating_mul(source.len() - 1) / count.saturating_sub(1);
source[source_index]
})
.collect()
}
#[cfg(test)]
mod command_replacement_tests {
use super::*;
use acadrust::entities::{Circle, Line};
use acadrust::types::Vector3;
#[test]
fn one_to_one_edits_keep_identity_but_type_changes_do_not() {
let mut app = OpenCADStudio::new_for_test();
let _ = app.automation_op(r#"{"op":"new"}"#);
let tab = app.active_tab;
let handle = app.tabs[tab].scene.add_entity(acadrust::EntityType::Line(
Line::from_points(Vector3::ZERO, Vector3::new(1.0, 0.0, 0.0)),
));
let owner = app.tabs[tab].scene.document.get_entity(handle).unwrap().common().owner_handle;
let kept = app.replace_command_entity(tab, handle, vec![acadrust::EntityType::Line(
Line::from_points(Vector3::ZERO, Vector3::new(3.0, 0.0, 0.0)),
)]);
assert_eq!(kept, vec![handle]);
let edited = app.tabs[tab].scene.document.get_entity(handle).unwrap();
assert_eq!(edited.common().owner_handle, owner);
assert!(matches!(edited, acadrust::EntityType::Line(line) if line.end.x == 3.0));
let allocated = app.replace_command_entity(tab, handle,
vec![acadrust::EntityType::Circle(Circle::from_coords(0.0, 0.0, 0.0, 2.0))]);
assert_eq!(allocated.len(), 1);
assert_ne!(allocated[0], handle);
assert!(app.tabs[tab].scene.document.get_entity(handle).is_none());
}
}
#[cfg(test)]
mod sketch_constraint_undo_tests {
use super::*;
use crate::scene::sketch_constraints::{ConstraintKind, SketchRef, SketchScope};
fn add_line(app: &mut OpenCADStudio, x1: f64, y1: f64, x2: f64, y2: f64) -> Handle {
app.tabs[app.active_tab]
.scene
.add_entity(acadrust::EntityType::Line(acadrust::entities::Line::from_points(
acadrust::types::Vector3::new(x1, y1, 0.0),
acadrust::types::Vector3::new(x2, y2, 0.0),
)))
}
fn line_angle_deg(app: &OpenCADStudio, handle: Handle) -> f64 {
match app.tabs[app.active_tab].scene.document.get_entity(handle) {
Some(acadrust::EntityType::Line(l)) => (l.end.y - l.start.y).atan2(l.end.x - l.start.x).to_degrees(),
other => panic!("expected a Line, got {other:?}"),
}
}
/// Constraint state and solved geometry share one undo/redo transaction.
#[test]
fn adding_a_constraint_is_undoable_and_redoable_atomically() {
let mut app = OpenCADStudio::new_for_test();
let _ = app.automation_op(r#"{"op":"new"}"#);
let line = add_line(&mut app, 0.0, 0.0, 10.0, 3.0);
let original_angle = line_angle_deg(&app, line);
assert!((original_angle - 16.699244).abs() < 1e-3);
let _ = app.apply_cmd_result(CmdResult::AddSketchConstraint {
kind: ConstraintKind::Horizontal,
refs: vec![SketchRef::whole(line)],
driving_param: None,
label: "Horizontal constraint",
});
assert!(line_angle_deg(&app, line).abs() < 1e-6, "constraint should have leveled the line");
assert_eq!(
app.tabs[app.active_tab].scene.sketch_constraint_set(SketchScope::ModelSpace).unwrap().constraints.len(),
1
);
app.undo_steps(1);
assert_eq!(
app.tabs[app.active_tab]
.scene
.sketch_constraint_set(SketchScope::ModelSpace)
.map(|s| s.constraints.len())
.unwrap_or(0),
0,
"undo should remove the constraint record"
);
assert!(
(line_angle_deg(&app, line) - original_angle).abs() < 1e-3,
"undo should restore the pre-constraint angle"
);
app.redo_steps(1);
assert!(line_angle_deg(&app, line).abs() < 1e-6, "redo should re-level the line");
assert_eq!(
app.tabs[app.active_tab].scene.sketch_constraint_set(SketchScope::ModelSpace).unwrap().constraints.len(),
1,
"redo should restore the constraint record"
);
}
/// Drawing commands must not create sketch constraints implicitly.
#[test]
fn drawing_a_line_onto_an_existing_endpoint_does_not_add_a_constraint() {
let mut app = OpenCADStudio::new_for_test();
let _ = app.automation_op(r#"{"op":"new"}"#);
let _existing = add_line(&mut app, 0.0, 0.0, 5.0, 0.0);
let new_line = acadrust::EntityType::Line(acadrust::entities::Line::from_points(
acadrust::types::Vector3::new(5.0, 0.0, 0.0), // exactly on `existing`'s end point
acadrust::types::Vector3::new(10.0, 0.0, 0.0),
));
app.tabs[app.active_tab].active_cmd = Some(Box::new(crate::modules::draw::draw::line::LineCommand::new()));
let _ = app.apply_cmd_result(CmdResult::CommitEntity(new_line));
assert_eq!(
app.tabs[app.active_tab]
.scene
.sketch_constraint_set(SketchScope::ModelSpace)
.map(|s| s.constraints.len())
.unwrap_or(0),
0,
"a drawing command must not add an implicit constraint"
);
}
/// Conflict resolution removes one flagged constraint and is undoable.
#[test]
fn resolve_one_sketch_conflict_removes_a_flagged_constraint_and_is_undoable() {
let mut app = OpenCADStudio::new_for_test();
let _ = app.automation_op(r#"{"op":"new"}"#);
let line = add_line(&mut app, 0.0, 0.0, 10.0, 3.0);
let _ = app.apply_cmd_result(CmdResult::AddSketchConstraint {
kind: ConstraintKind::Horizontal,
refs: vec![SketchRef::whole(line)],
driving_param: None,
label: "Horizontal constraint",
});
let _ = app.apply_cmd_result(CmdResult::AddSketchConstraint {
kind: ConstraintKind::Horizontal,
refs: vec![SketchRef::whole(line)],
driving_param: None,
label: "Horizontal constraint",
}); // exact duplicate — flagged redundant once bumped
app.tabs[app.active_tab].scene.bump_entities(&[(line, crate::scene::ChangeKind::Modified)]);
let set = app.tabs[app.active_tab].scene.sketch_constraint_set(SketchScope::ModelSpace).unwrap();
assert_eq!(set.constraints.len(), 2);
assert_eq!(set.conflicts.len(), 1, "the duplicate should have been flagged");
app.resolve_one_sketch_conflict();
let set = app.tabs[app.active_tab].scene.sketch_constraint_set(SketchScope::ModelSpace).unwrap();
assert_eq!(set.constraints.len(), 1, "one of the two duplicates should have been removed");
app.undo_steps(1);
let set = app.tabs[app.active_tab].scene.sketch_constraint_set(SketchScope::ModelSpace).unwrap();
assert_eq!(set.constraints.len(), 2, "undo should restore the removed constraint");
}
/// `named_parameters_design.md` stage 4: applying the PARAMETERS editor's
/// working buffer both defines the parameter and re-solves whatever
/// constraint already references it by name — the entire point of a
/// *named* parameter, not just data plumbing.
#[test]
fn applying_named_parameter_rows_defines_and_resolves_referencing_constraints() {
let mut app = OpenCADStudio::new_for_test();
let _ = app.automation_op(r#"{"op":"new"}"#);
let line = add_line(&mut app, 0.0, 0.0, 10.0, 0.0);
// The constraint is added *before* the parameter exists -- allowed,
// same as `ParameterTable::set`'s own forward-reference tolerance;
// it just doesn't resolve to anything until the parameter is defined.
let _ = app.apply_cmd_result(CmdResult::AddSketchConstraint {
kind: ConstraintKind::Distance,
refs: vec![SketchRef::point(line, 0), SketchRef::point(line, 1)],
driving_param: Some(crate::scene::named_parameters::DrivingValue::Named("target_len".to_string())),
label: "Distance constraint",
});
app.named_parameter_editor_rows =
vec![crate::ui::window::named_parameters::ParamEditorRow { name: "target_len".to_string(), formula: "8".to_string() }];
app.apply_named_parameter_editor_rows();
assert_eq!(app.tabs[app.active_tab].scene.named_parameters().resolve("target_len"), Ok(8.0));
let (start, end) = match app.tabs[app.active_tab].scene.document.get_entity(line) {
Some(acadrust::EntityType::Line(l)) => (l.start, l.end),
other => panic!("expected a Line, got {other:?}"),
};
let len = ((end.x - start.x).powi(2) + (end.y - start.y).powi(2)).sqrt();
assert!((len - 8.0).abs() < 1e-6, "length should track the applied parameter, got {len}");
// Redefining the parameter and re-applying should ripple again.
app.named_parameter_editor_rows[0].formula = "3".to_string();
app.apply_named_parameter_editor_rows();
let (start, end) = match app.tabs[app.active_tab].scene.document.get_entity(line) {
Some(acadrust::EntityType::Line(l)) => (l.start, l.end),
other => panic!("expected a Line, got {other:?}"),
};
let len = ((end.x - start.x).powi(2) + (end.y - start.y).powi(2)).sqrt();
assert!((len - 3.0).abs() < 1e-6, "length should track the redefined parameter, got {len}");
app.undo_steps(1);
assert_eq!(app.tabs[app.active_tab].scene.named_parameters().resolve("target_len"), Ok(8.0));
let length_after_undo = match app.tabs[app.active_tab].scene.document.get_entity(line) {
Some(acadrust::EntityType::Line(line)) => line.length(),
other => panic!("expected a Line, got {other:?}"),
};
assert!((length_after_undo - 8.0).abs() < 1e-6);
app.redo_steps(1);
assert_eq!(app.tabs[app.active_tab].scene.named_parameters().resolve("target_len"), Ok(3.0));
}
/// End-to-end through the actual `Message` handlers a Properties-panel
/// click/keystroke would fire — not `apply_named_parameter_editor_rows`
/// directly — covering the whole Parameters-section lifecycle: add,
/// rename, redefine, and confirm a referencing constraint re-solves
/// after each commit (not just after a whole-table Apply, since this
/// path commits per field).
#[test]
fn properties_panel_parameter_row_add_rename_redefine_and_delete() {
use crate::ui::window::named_parameters::ParamField;
let mut app = OpenCADStudio::new_for_test();
let _ = app.automation_op(r#"{"op":"new"}"#);
let line = add_line(&mut app, 0.0, 0.0, 10.0, 0.0);
let _ = app.apply_cmd_result(CmdResult::AddSketchConstraint {
kind: ConstraintKind::Distance,
refs: vec![SketchRef::point(line, 0), SketchRef::point(line, 1)],
driving_param: Some(crate::scene::named_parameters::DrivingValue::Named("len".to_string())),
label: "Distance constraint",
});
// Add: the first row is auto-named "param1".
let _ = app.update(Message::PropParamAddNew);
assert!(app.tabs[app.active_tab].scene.named_parameters().contains("param1"));
let index = app.tabs[app.active_tab].scene.named_parameters().iter().position(|p| p.name == "param1").unwrap();
// Rename param1 -> len (commit-on-submit, not per keystroke: input
// alone must not touch the table yet).
let _ = app.update(Message::PropParamInput { index, field: ParamField::Name, value: "len".to_string() });
assert!(app.tabs[app.active_tab].scene.named_parameters().contains("param1"), "typing alone must not commit");
let _ = app.update(Message::PropParamCommit { index, field: ParamField::Name });
assert!(!app.tabs[app.active_tab].scene.named_parameters().contains("param1"));
assert!(app.tabs[app.active_tab].scene.named_parameters().contains("len"));
// Redefine its formula to 8 and confirm the Distance constraint
// (already referencing "len" by name, defined before this) re-solves.
let _ = app.update(Message::PropParamInput { index, field: ParamField::Formula, value: "8".to_string() });
let _ = app.update(Message::PropParamCommit { index, field: ParamField::Formula });
assert_eq!(app.tabs[app.active_tab].scene.named_parameters().resolve("len"), Ok(8.0));
let (start, end) = match app.tabs[app.active_tab].scene.document.get_entity(line) {
Some(acadrust::EntityType::Line(l)) => (l.start, l.end),
other => panic!("expected a Line, got {other:?}"),
};
let len = ((end.x - start.x).powi(2) + (end.y - start.y).powi(2)).sqrt();
assert!((len - 8.0).abs() < 1e-6, "length should track the redefined parameter, got {len}");
// Delete: the row is gone from the table.
let _ = app.update(Message::PropParamDelete(index));
assert!(!app.tabs[app.active_tab].scene.named_parameters().contains("len"));
}
/// A Constraints-section row click selects every entity the constraint
/// references, replacing whatever was selected before.
#[test]
fn properties_panel_constraint_link_click_selects_its_entities() {
let mut app = OpenCADStudio::new_for_test();
let _ = app.automation_op(r#"{"op":"new"}"#);
let a = add_line(&mut app, 0.0, 0.0, 10.0, 0.0);
let b = add_line(&mut app, 0.0, 5.0, 10.0, 5.0);
let unrelated = add_line(&mut app, 20.0, 20.0, 30.0, 20.0);
let _ = app.apply_cmd_result(CmdResult::AddSketchConstraint {
kind: ConstraintKind::Parallel,
refs: vec![SketchRef::whole(a), SketchRef::whole(b)],
driving_param: None,
label: "Parallel constraint",
});
app.tabs[app.active_tab].scene.select_entity(unrelated, true);
let _ = app.update(Message::PropConstraintLinkClick(vec![a, b]));
let selected: std::collections::HashSet<Handle> =
app.tabs[app.active_tab].scene.selected_entities().into_iter().map(|(h, _)| h).collect();
assert_eq!(selected, std::collections::HashSet::from([a, b]));
}
/// A row that fails to validate (here, a formula that doesn't parse)
/// must not silently vanish or corrupt the table: it's reported as an
/// error and simply isn't added, while a valid row alongside it still
/// commits — the same isolate-the-failure treatment
/// `ParameterTable::resolve_all` already gives a bad reference.
#[test]
fn applying_a_row_with_a_malformed_formula_reports_an_error_and_keeps_the_good_row() {
let mut app = OpenCADStudio::new_for_test();
let _ = app.automation_op(r#"{"op":"new"}"#);
app.named_parameter_editor_rows = vec![
crate::ui::window::named_parameters::ParamEditorRow { name: "good".to_string(), formula: "10".to_string() },
crate::ui::window::named_parameters::ParamEditorRow { name: "bad".to_string(), formula: "1 +".to_string() },
];
app.apply_named_parameter_editor_rows();
assert_eq!(app.tabs[app.active_tab].scene.named_parameters().resolve("good"), Ok(10.0));
assert!(!app.tabs[app.active_tab].scene.named_parameters().contains("bad"), "a malformed row must not be added");
assert!(
app.command_line.history.iter().any(|e| e.kind == crate::ui::command_line::EntryKind::Error),
"a malformed row must be reported as an error, not silently dropped"
);
}
/// Two rows typed with the same name must not silently collapse into
/// "whichever `set` call happens last wins" — both are refused, and an
/// unrelated row still commits normally.
#[test]
fn applying_rows_with_a_duplicate_name_refuses_both_and_keeps_the_unrelated_row() {
let mut app = OpenCADStudio::new_for_test();
let _ = app.automation_op(r#"{"op":"new"}"#);
app.named_parameter_editor_rows = vec![
crate::ui::window::named_parameters::ParamEditorRow { name: "x".to_string(), formula: "1".to_string() },
crate::ui::window::named_parameters::ParamEditorRow { name: "x".to_string(), formula: "2".to_string() },
crate::ui::window::named_parameters::ParamEditorRow { name: "y".to_string(), formula: "3".to_string() },
];
app.apply_named_parameter_editor_rows();
assert!(!app.tabs[app.active_tab].scene.named_parameters().contains("x"), "a duplicated name must not be added at all");
assert_eq!(app.tabs[app.active_tab].scene.named_parameters().resolve("y"), Ok(3.0));
assert!(app.command_line.history.iter().any(|e| e.kind == crate::ui::command_line::EntryKind::Error));
}
/// Two endpoint picks resolve to a Coincident constraint.
#[test]
fn coincident_command_resolves_two_picked_points_into_a_constraint() {
let mut app = OpenCADStudio::new_for_test();
let _ = app.automation_op(r#"{"op":"new"}"#);
let a = add_line(&mut app, 0.0, 0.0, 5.0, 0.0);
let b = add_line(&mut app, 20.0, 0.0, 20.0, 5.0); // deliberately far from `a`, so the two picks are unambiguous
let _ = app.apply_cmd_result(CmdResult::AddCoincidentConstraint {
point_a: glam::DVec3::new(5.0, 0.0, 0.0), // a's end
point_b: glam::DVec3::new(20.0, 0.0, 0.0), // b's start
label: "Coincident constraint",
});
let set = app.tabs[app.active_tab].scene.sketch_constraint_set(SketchScope::ModelSpace).expect("a constraint set should exist");
assert_eq!(set.constraints.len(), 1);
let c = &set.constraints[0];
assert_eq!(c.kind, ConstraintKind::Coincident);
let entities: Vec<Handle> = c.refs.iter().map(|r| r.entity).collect();
assert!(entities.contains(&a) && entities.contains(&b), "the constraint should reference both lines");
}
/// A pick that doesn't land near any real point (no object snap match)
/// must not add a constraint — it should report an error and leave the
/// scope untouched rather than silently constraining the wrong thing.
#[test]
fn coincident_command_reports_an_error_when_a_pick_misses() {
let mut app = OpenCADStudio::new_for_test();
let _ = app.automation_op(r#"{"op":"new"}"#);
let _a = add_line(&mut app, 0.0, 0.0, 5.0, 0.0);
let _ = app.apply_cmd_result(CmdResult::AddCoincidentConstraint {
point_a: glam::DVec3::new(5.0, 0.0, 0.0), // a's end — a real point
point_b: glam::DVec3::new(500.0, 500.0, 0.0), // nowhere near anything
label: "Coincident constraint",
});
assert_eq!(
app.tabs[app.active_tab]
.scene
.sketch_constraint_set(SketchScope::ModelSpace)
.map(|s| s.constraints.len())
.unwrap_or(0),
0,
"a missed pick must not add a constraint"
);
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod delobj_tests {
use super::*;
use acadrust::types::Vector3;
fn sweep_source_presence(value: i16, mode: crate::command::ExtrudeMode) -> (bool, bool) {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let i = app.active_tab;
let mut circle = acadrust::Circle::new();
circle.radius = 2.0;
let profile = app.tabs[i]
.scene
.add_entity(acadrust::EntityType::Circle(circle));
let path = app.tabs[i].scene.add_entity(acadrust::EntityType::Line(
acadrust::Line::from_points(
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(0.0, 0.0, 5.0),
),
));
app.delete_objects = value;
let _ = app.apply_cmd_result(CmdResult::SweepEntities {
handles: vec![profile],
path_handle: path,
mode,
options: crate::command::SweepOptions::default(),
color: [1.0; 4],
});
let created = app.tabs[i].scene.document.entities().any(|entity| match mode {
crate::command::ExtrudeMode::Solid => {
matches!(entity, acadrust::EntityType::Solid3D(_))
}
crate::command::ExtrudeMode::Surface => {
matches!(entity, acadrust::EntityType::Surface(_))
}
});
assert!(created, "SWEEP must produce the requested result");
(
app.tabs[i].scene.document.get_entity(profile).is_some(),
app.tabs[i].scene.document.get_entity(path).is_some(),
)
}
#[test]
fn sweep_applies_all_four_source_deletion_modes() {
let solid_expectations = [(true, true), (false, true), (false, false), (false, false)];
for (value, expected) in solid_expectations.into_iter().enumerate() {
assert_eq!(
sweep_source_presence(value as i16, crate::command::ExtrudeMode::Solid),
expected,
);
}
assert_eq!(
sweep_source_presence(2, crate::command::ExtrudeMode::Surface),
(false, false),
);
assert_eq!(
sweep_source_presence(3, crate::command::ExtrudeMode::Surface),
(false, true),
);
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod thicken_tests {
use super::*;
use acadrust::entities::{Surface, SurfaceKind};
use cadkernel::geom2d::{Circle, Curve, NurbsCurve};
use cadkernel::space::{Parameterization, Plane};
#[test]
fn thicken_preserves_sources_and_round_trips_results_with_one_undo() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let body = cadkernel::brep::planar_region(Plane::XY, &[
vec![Curve::Circle(Circle { centre: [0.0; 2], radius: 3.0 })],
]).unwrap();
let source = app.add_surface_model(acadrust::EntityType::Surface(Surface::new(SurfaceKind::Plane)), body);
assert!(!source.is_null());
let i = app.active_tab;
let invalid = app.tabs[i].scene.add_entity(acadrust::EntityType::Surface(Surface::new(SurfaceKind::Generic)));
let original = serde_json::to_value(app.tabs[i].scene.document.get_entity(source).unwrap()).unwrap();
let before = app.tabs[i].history.undo_stack.len();
let _ = app.apply_cmd_result(CmdResult::ThickenEntities { handles: vec![source], distance: 0.0 });
assert_eq!(app.tabs[i].history.undo_stack.len(), before);
let _ = app.apply_cmd_result(CmdResult::ThickenEntities { handles: vec![invalid, source], distance: 2.0 });
assert_eq!(app.tabs[i].history.undo_stack.len(), before + 1);
let result = app.tabs[i].scene.document.entities().find_map(|entity| {
matches!(entity, acadrust::EntityType::Solid3D(_)).then_some(entity.common().handle)
}).unwrap();
assert!(app.tabs[i].scene.document.solid_history_operation(result).is_some());
assert_eq!(serde_json::to_value(app.tabs[i].scene.document.get_entity(source).unwrap()).unwrap(), original);
let expected = cadkernel::brep::analytic_mass_properties(&app.tabs[i].scene.solid_models[&result]).unwrap().volume;
let bytes = crate::io::save_to_bytes(&app.tabs[i].scene.document, "dwg", app.tabs[i].scene.document.version).unwrap();
let document = crate::io::load_bytes("thicken.dwg", bytes).unwrap();
let mut restored = crate::scene::Scene::new();
restored.document = document;
restored.restore_solid_models(&[result]);
let actual = cadkernel::brep::analytic_mass_properties(&restored.solid_models[&result]).unwrap().volume;
assert!((expected - actual).abs() < 1e-8);
app.undo_active_tab();
assert!(app.tabs[i].scene.document.get_entity(result).is_none());
assert!(app.tabs[i].scene.document.solid_history_operation(result).is_none());
assert!(app.tabs[i].scene.document.get_entity(source).is_some());
assert!(app.tabs[i].scene.document.get_entity(invalid).is_some());
app.redo_active_tab();
assert!(app.tabs[i].scene.document.get_entity(result).is_some());
assert!(app.tabs[i].scene.document.solid_history_operation(result).is_some());
assert_eq!(serde_json::to_value(app.tabs[i].scene.document.get_entity(source).unwrap()).unwrap(), original);
}
#[test]
fn thicken_creates_a_solid_from_a_free_form_surface() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let profile = NurbsCurve::interpolate(
&[[0.0, 0.0], [0.7, 0.15], [1.3, -0.1], [2.0, 0.0]],
None,
None,
Parameterization::Chord,
)
.unwrap();
let body = cadkernel::brep::extrude_surface(
Plane::XY,
&[Curve::Nurbs(profile)],
[0.0, 0.0, 2.0],
)
.unwrap();
let source = app.add_surface_model(
acadrust::EntityType::Surface(Surface::new(SurfaceKind::Generic)),
body,
);
let i = app.active_tab;
let _ = app.apply_cmd_result(CmdResult::ThickenEntities {
handles: vec![source],
distance: 0.15,
});
let solids = app.tabs[i]
.scene
.document
.entities()
.filter_map(|entity| {
matches!(entity, acadrust::EntityType::Solid3D(_))
.then_some(entity.common().handle)
})
.collect::<Vec<_>>();
assert_eq!(solids.len(), 1);
let solid = &app.tabs[i].scene.solid_models[&solids[0]];
assert!(solid.validate().is_empty());
assert!(solid.edges.iter().all(|(_, edge)| edge.coedges.len() == 2));
assert!(app.tabs[i]
.scene
.document
.solid_history_operation(solids[0])
.is_some());
assert!(app.tabs[i].scene.document.get_entity(source).is_some());
}
}