cad-editor/src/app/command_driver.rs

4427 lines
201 KiB
Rust
Raw Normal View History

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;
self.snapper.from_point = None;
self.snapper.clear_tracking();
self.otrack_active = None;
2026-08-08 23:09:06 -03:00
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_snap_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
2026-08-02 22:09:25 +03:00
.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
2026-08-02 22:09:25 +03:00
.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) {
2026-08-02 22:09:25 +03:00
self.command_line.push_error(crate::t!("Outside limits.").as_ref());
return false;
}
true
}
/// 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();
}
}
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::SelectionComplete(handles) = &input {
let entities = {
let scene = &self.tabs[i].scene;
handles
.iter()
.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);
}
}
let ctrl = self.ctrl_down;
feat(trim): TRIM/EXTEND options — edges, fence, crossing, edge, erase (#336) Both commands gain a shared option mode machine: - Cutting/Boundary edges (T/B): pick which objects act as the boundary (picked edges highlight yellow, the hovered candidate half-strength); Enter rebuilds the boundary set, empty = everything (quick mode). - Fence (F): draw a dashed fence; every object crossing it is trimmed / extended at each crossing, re-applied to the surviving pieces so a fence across both overhanging ends clears both. Live red/cyan result preview while placing points. - Crossing (C): crossing-selection semantics — the dashed green window picks objects crossing its edges AND wholly inside; runs through the fence machinery with a synthesized inside click. - Edge (E): boundaries extrapolate (lines run past their ends, arcs close to full circles). Cuts that land on an implied extension draw a dashed yellow guide from the boundary's drawn end to the cut point, so the user sees which edge causes the cut. Extend previews also show the added tail as a bright dashed slice of the true curve. - Erase (R, TRIM): picks delete without needing an intersection, with a red hover preview. - Shift+click swaps trim<->extend per pick (new set_shift plumbing into the entity-pick and hover paths); the hover preview follows the held state. Project is out of scope for the 2D pipeline. The per-type pick bodies moved to shared free fns (pick_trim_at / pick_extend_at) used by direct picks, hover previews and the fence pass; EXTEND's snapshot bookkeeping unified with TRIM's placeholder flow. Option keywords now take precedence over the hex-handle reading in feed_active_cmd ("F"/"C"/"E" are valid hex), and fence geos carry sentinel handles — probing with NULL against NULL-handle geos excluded the whole fence. Regression test: fence_trims_x_arm. Closes #336 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 14:53:50 +03:00
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);
feat(trim): TRIM/EXTEND options — edges, fence, crossing, edge, erase (#336) Both commands gain a shared option mode machine: - Cutting/Boundary edges (T/B): pick which objects act as the boundary (picked edges highlight yellow, the hovered candidate half-strength); Enter rebuilds the boundary set, empty = everything (quick mode). - Fence (F): draw a dashed fence; every object crossing it is trimmed / extended at each crossing, re-applied to the surviving pieces so a fence across both overhanging ends clears both. Live red/cyan result preview while placing points. - Crossing (C): crossing-selection semantics — the dashed green window picks objects crossing its edges AND wholly inside; runs through the fence machinery with a synthesized inside click. - Edge (E): boundaries extrapolate (lines run past their ends, arcs close to full circles). Cuts that land on an implied extension draw a dashed yellow guide from the boundary's drawn end to the cut point, so the user sees which edge causes the cut. Extend previews also show the added tail as a bright dashed slice of the true curve. - Erase (R, TRIM): picks delete without needing an intersection, with a red hover preview. - Shift+click swaps trim<->extend per pick (new set_shift plumbing into the entity-pick and hover paths); the hover preview follows the held state. Project is out of scope for the 2D pipeline. The per-type pick bodies moved to shared free fns (pick_trim_at / pick_extend_at) used by direct picks, hover previews and the fence pass; EXTEND's snapshot bookkeeping unified with TRIM's placeholder flow. Option keywords now take precedence over the hex-handle reading in feed_active_cmd ("F"/"C"/"E" are valid hex), and fence geos carry sentinel handles — probing with NULL against NULL-handle geos excluded the whole fence. Regression test: fence_trims_x_arm. Closes #336 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 14:53:50 +03:00
cmd.set_shift(shift);
match input {
StepInput::Point(p) => Some(cmd.on_point(p)),
StepInput::Text(s) => cmd.on_text_input(&s),
StepInput::EntityPick(h, p) => Some(cmd.on_entity_pick(h, p)),
StepInput::StructurePick(h, p) => Some(cmd.on_structure_pick(h, p)),
StepInput::SelectionComplete(hs) => Some(cmd.on_selection_complete(hs)),
StepInput::Tangent(o, p) => Some(cmd.on_tangent_point(o, p)),
StepInput::EditorClosed(c) => Some(cmd.on_editor_closed(c)),
StepInput::Enter => Some(cmd.on_enter()),
StepInput::Escape => Some(cmd.on_escape()),
}
};
match result {
Some(r) => self.apply_cmd_result(r),
None => Task::none(),
}
}
/// Run one whole command-line string. A single word or an inline-argument
/// command (`PDMODE 3`, `LAYER Walls`, `UCS Z 90` pasted as one line)
/// dispatches as-is; for a multi-token line whose first word starts an
/// interactive tool (`LINE 0,0 10,10`) the first word starts the tool and the
/// remaining tokens are fed as points / option keywords, then the command is
/// terminated as if Enter were pressed. Shared by the GUI command line and
/// the headless automation feeder so both behave identically.
pub(super) fn run_command_line(&mut self, cmd: &str) -> Task<Message> {
let i = self.active_tab;
let tokens: Vec<&str> = cmd.split_whitespace().collect();
if tokens.len() <= 1 {
return self.dispatch_command(cmd);
}
// Plugin commands parse their own inline arguments from the whole line
// (e.g. `HC_PIPE 2B 2C 1.25 0.013`), so offer the full command to plugin
// dispatch first. A built-in interactive tool matches only its bare name
// (`LINE`), so the full line is not a plugin command and falls through to
// the first-word + fed-tokens path below. (#162)
if crate::plugin::try_dispatch(self, i, cmd) {
let toks: Vec<String> = tokens.iter().map(|s| s.to_string()).collect();
self.finish_active_command(&toks);
return Task::none();
}
let _ = self.dispatch_command(tokens[0]);
if self.tabs[i].active_cmd.is_none() {
// Not an interactive tool — an inline-argument command (`PDMODE 3`).
return self.dispatch_command(cmd);
}
let toks: Vec<String> = tokens.iter().map(|s| s.to_string()).collect();
self.finish_active_command(&toks);
Task::none()
}
/// Feed `tokens[1..]` to the active interactive command as points / option
/// keywords, then terminate it as if Enter were pressed. No-op when no
/// command is active.
pub(super) fn finish_active_command(&mut self, tokens: &[String]) {
let i = self.active_tab;
if self.tabs[i].active_cmd.is_none() {
return;
}
self.last_point = None;
for tok in &tokens[1..] {
if self.tabs[i].active_cmd.is_none() {
break;
}
self.feed_active_cmd(tok);
}
let _ = self.feed_command(StepInput::Enter);
}
/// Classify one typed token into a [`StepInput`] and route it through the
/// shared [`Self::feed_command`]. An object-pick step takes a hex handle; a
/// coordinate is parsed (and, like the GUI command line, interpreted in the
/// active UCS); anything else is an option keyword / value. Used by both the
/// GUI command line and headless automation.
/// Standard selection keywords at a "Select objects:" prompt (#426):
/// P / PREVIOUS re-selects the set the last command worked on, and
/// L / LAST selects the most recently created object in the current
/// space. Returns `Some` when the token was consumed as a keyword.
/// Handled centrally so every gathering command gets them for free.
pub(super) fn try_selection_keyword(&mut self, text: &str) -> Option<Task<Message>> {
let i = self.active_tab;
let gathering = self.tabs[i]
.active_cmd
.as_ref()
.map_or(false, |c| c.is_selection_gathering());
if !gathering {
return None;
}
let kw = text.trim().to_ascii_uppercase();
feat(select): offer the selection modes at "Select objects" Only Previous and Last were on offer, so Window, Crossing, Fence, the two polygons and All could not be reached at all -- and with no keyboard to type them into, the buttons were the only way in. Window and Crossing fix the sense of the next box. Dragging derives that from the direction the corner travels and would overwrite it the moment the corner moved, so the derivation stands down while a keyword holds it. Add and Remove decide what a pick does for the rest of the round: Remove subtracts the way Shift already does, rather than through PICKADD, which decides whether a pick *replaces* the set and so would have wiped everything gathered so far. Both are dropped when the next command starts, since neither outlives the selection that asked for them. Fence and the polygons are picked point by point, which TRIM and EXTEND already ask for. That gesture -- the points, the rubber-banded dashed preview, whether the path closes -- moves into one place both bind to, and the two preview builders duplicated in trim.rs go with it. A fence is not the polygon test with crossing set: that closes the point list and counts whatever lies inside, so a line drawn past a row of objects would take everything behind it too. It gets a hit test that only counts a real intersection, and skips the area-based geometry entirely. The lasso gathered its hits from wires, hatches, block hatches, meshes and block meshes inline; the new modes need the same list, and a second copy would drift. Both now come through `Scene::path_hit_handles`. Closes #596 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 13:32:11 +03:00
// 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() {
feat(select): offer the selection modes at "Select objects" Only Previous and Last were on offer, so Window, Crossing, Fence, the two polygons and All could not be reached at all -- and with no keyboard to type them into, the buttons were the only way in. Window and Crossing fix the sense of the next box. Dragging derives that from the direction the corner travels and would overwrite it the moment the corner moved, so the derivation stands down while a keyword holds it. Add and Remove decide what a pick does for the rest of the round: Remove subtracts the way Shift already does, rather than through PICKADD, which decides whether a pick *replaces* the set and so would have wiped everything gathered so far. Both are dropped when the next command starts, since neither outlives the selection that asked for them. Fence and the polygons are picked point by point, which TRIM and EXTEND already ask for. That gesture -- the points, the rubber-banded dashed preview, whether the path closes -- moves into one place both bind to, and the two preview builders duplicated in trim.rs go with it. A fence is not the polygon test with crossing set: that closes the point list and counts whatever lies inside, so a line drawn past a row of objects would take everything behind it too. It gets a hit test that only counts a real intersection, and skips the area-based geometry entirely. The lasso gathered its hits from wires, hatches, block hatches, meshes and block meshes inline; the new modes need the same list, and a second copy would drift. Both now come through `Scene::path_hit_handles`. Closes #596 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 13:32:11 +03:00
// 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(match kw.as_str() {
"P" | "PREVIOUS" => "No previous selection set.",
feat(select): offer the selection modes at "Select objects" Only Previous and Last were on offer, so Window, Crossing, Fence, the two polygons and All could not be reached at all -- and with no keyboard to type them into, the buttons were the only way in. Window and Crossing fix the sense of the next box. Dragging derives that from the direction the corner travels and would overwrite it the moment the corner moved, so the derivation stands down while a keyword holds it. Add and Remove decide what a pick does for the rest of the round: Remove subtracts the way Shift already does, rather than through PICKADD, which decides whether a pick *replaces* the set and so would have wiped everything gathered so far. Both are dropped when the next command starts, since neither outlives the selection that asked for them. Fence and the polygons are picked point by point, which TRIM and EXTEND already ask for. That gesture -- the points, the rubber-banded dashed preview, whether the path closes -- moves into one place both bind to, and the two preview builders duplicated in trim.rs go with it. A fence is not the polygon test with crossing set: that closes the point list and counts whatever lies inside, so a line drawn past a row of objects would take everything behind it too. It gets a hit test that only counts a real intersection, and skips the area-based geometry entirely. The lasso gathered its hits from wires, hatches, block hatches, meshes and block meshes inline; the new modes need the same list, and a second copy would drift. Both now come through `Scene::path_hit_handles`. Closes #596 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 13:32:11 +03:00
"ALL" => "Nothing to select.",
_ => "No last object.",
});
return Some(Task::none());
}
let count = add.len();
for h in add {
self.tabs[i].scene.select_entity(h, false);
}
self.command_line
2026-08-02 22:09:25 +03:00
.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) {
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())
{
feat(trim): TRIM/EXTEND options — edges, fence, crossing, edge, erase (#336) Both commands gain a shared option mode machine: - Cutting/Boundary edges (T/B): pick which objects act as the boundary (picked edges highlight yellow, the hovered candidate half-strength); Enter rebuilds the boundary set, empty = everything (quick mode). - Fence (F): draw a dashed fence; every object crossing it is trimmed / extended at each crossing, re-applied to the surviving pieces so a fence across both overhanging ends clears both. Live red/cyan result preview while placing points. - Crossing (C): crossing-selection semantics — the dashed green window picks objects crossing its edges AND wholly inside; runs through the fence machinery with a synthesized inside click. - Edge (E): boundaries extrapolate (lines run past their ends, arcs close to full circles). Cuts that land on an implied extension draw a dashed yellow guide from the boundary's drawn end to the cut point, so the user sees which edge causes the cut. Extend previews also show the added tail as a bright dashed slice of the true curve. - Erase (R, TRIM): picks delete without needing an intersection, with a red hover preview. - Shift+click swaps trim<->extend per pick (new set_shift plumbing into the entity-pick and hover paths); the hover preview follows the held state. Project is out of scope for the 2D pipeline. The per-type pick bodies moved to shared free fns (pick_trim_at / pick_extend_at) used by direct picks, hover previews and the fence pass; EXTEND's snapshot bookkeeping unified with TRIM's placeholder flow. Option keywords now take precedence over the hex-handle reading in feed_active_cmd ("F"/"C"/"E" are valid hex), and fence geos carry sentinel handles — probing with NULL against NULL-handle geos excluded the whole fence. Regression test: fence_trims_x_arm. Closes #336 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 14:53:50 +03:00
// Option keywords take precedence over the handle reading —
// "F"/"C"/"E" are valid hex, but during TRIM/EXTEND they are the
// Fence/Crossing/Edge options (#336). Only an unconsumed token
// falls through to the handle interpretation.
let consumed = self.tabs[i]
.active_cmd
.as_mut()
.and_then(|c| c.on_text_input(token));
if let Some(r) = consumed {
let _ = self.apply_cmd_result(r);
return;
}
if let Ok(v) = u64::from_str_radix(token.trim_start_matches("0x"), 16) {
let handle = Handle::new(v);
let pt = self.tabs[i]
.scene
.document
.get_entity(handle)
.map(|e| {
let bb = e.as_entity().bounding_box();
glam::Vec3::new(
((bb.min.x + bb.max.x) * 0.5) as f32,
((bb.min.y + bb.max.y) * 0.5) as f32,
0.0,
)
})
.unwrap_or(glam::Vec3::ZERO);
let _ = self.feed_command(StepInput::EntityPick(handle, pt.as_dvec3()));
}
return;
}
if let Some((coord, kind)) = super::helpers::parse_coord(token) {
// Match the GUI command line: typed coordinates are in the active
// UCS (relative offsets are rotated by the UCS axes), so a multi-
// token `LINE 0,0 10,10` under a rotated UCS lands correctly.
let ucs = self.tabs[i].active_ucs.clone();
let wcs = match (
matches!(kind, super::helpers::CoordKind::Relative),
self.last_point,
) {
(true, Some(base)) => {
base + match &ucs {
Some(u) => super::helpers::ucs_rotate_vec(coord, u),
None => coord,
}
}
_ => match &ucs {
Some(u) => super::helpers::ucs_to_wcs(coord, u),
None => coord,
},
};
if !self.command_point_allowed(i, wcs) {
return;
}
self.last_point = Some(wcs);
self.push_ucs_to_cmd(i);
let _ = self.feed_command(StepInput::Point(wcs));
} else {
let _ = self.feed_command(StepInput::Text(token.to_string()));
}
}
/// Applies one command result, then — when that result ended the active
/// command — drops the selection. Editing tools (MOVE, COPY, ROTATE, …) and
/// every other interactive command leave nothing selected once they finish,
/// so a follow-up edit doesn't silently reuse the previous working set.
///
/// `Relaunch`/`Dispatch` are excepted: they end the front-end command only
/// to immediately start another and hand it a deliberate selection (the
/// pick-first selector relaunching MOVE on the picked set works this way).
/// Pure-selection commands (SELECTALL, QSELECT, …) run without an active
/// command, so `was_active` is false and their selection is preserved.
pub(super) fn apply_cmd_result(&mut self, result: CmdResult) -> Task<Message> {
let was_active = self.tabs[self.active_tab].active_cmd.is_some();
let preserve_selection =
matches!(result, CmdResult::Relaunch(..) | CmdResult::Dispatch(..));
let task = self.apply_cmd_result_inner(result);
let i = self.active_tab;
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.restore_add_selected_defaults();
}
task
}
fn apply_cmd_result_inner(&mut self, result: CmdResult) -> Task<Message> {
let i = self.active_tab;
match result {
CmdResult::NeedPoint => {
// ATTEDIT finished its entity pick: hand the chosen block off to
// the attribute editor dialog and end the command (open_attribute
// _editor reports "no attributes" / "select a block" as needed).
let attedit_handle = self.tabs[i]
.active_cmd
.as_ref()
.and_then(|c| c.attedit_pending_handle());
if let Some(ins_handle) = attedit_handle {
self.tabs[i].active_cmd = None;
self.open_attribute_editor(ins_handle);
return Task::none();
}
let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt());
if let Some(p) = prompt {
self.command_line.push_info(&p);
}
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(_)
) {
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.
perf(undo): record entity deltas instead of cloning the whole document Frequent entity-only edits (MOVE/ROTATE/SCALE/MIRROR/ALIGN, COPY/ARRAY, draw-add, ERASE) captured a full ~800k-entity CadDocument clone (~46ms) on every edit — the last big per-edit cost after the incremental-render work. They now record only the touched entities' before/after images: - Scene self-records through the five mutation primitives when a recording is open (UndoRecording + is_recording_undo guard, zero cost off-path). - apply_entity_delta installs one side per handle (in-place / re-insert with the original handle / remove) plus a per-handle cache reseed, then a single bump_entities so the incremental caches patch instead of a full re-tess — undo/redo of a small edit is fast too, not just the edit. - HistorySnapshot is now Full | Delta; a Delta is self-inverting and moves between the undo/redo stacks without re-capturing. Commands that touch non-entity state (new layer, group, *D block, viewport) fall back to the full snapshot via per-command predicates, and a poison flag debug-asserts any mis-scope. remove_entity leaves the handle dangling in its owner block record's entity_handles (harmless on its own, a lookup miss is skipped), so a re-insert strips stale entries first — otherwise add_entity would list the handle twice and the DXF/DWG writer would emit the entity twice. Round-trip tests cover transform/erase/add/copy (do->undo->redo equality, handle preservation, no duplication). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 11:11:17 +03:00
let delta_safe = self.delta_add_safe(i, &entity);
let pending = self.begin_undo(i, label, 1, delta_safe);
self.commit_entity(entity);
self.tabs[i].dirty = true;
let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt());
if let Some(p) = prompt {
self.command_line.push_info(&p);
}
perf(undo): record entity deltas instead of cloning the whole document Frequent entity-only edits (MOVE/ROTATE/SCALE/MIRROR/ALIGN, COPY/ARRAY, draw-add, ERASE) captured a full ~800k-entity CadDocument clone (~46ms) on every edit — the last big per-edit cost after the incremental-render work. They now record only the touched entities' before/after images: - Scene self-records through the five mutation primitives when a recording is open (UndoRecording + is_recording_undo guard, zero cost off-path). - apply_entity_delta installs one side per handle (in-place / re-insert with the original handle / remove) plus a per-handle cache reseed, then a single bump_entities so the incremental caches patch instead of a full re-tess — undo/redo of a small edit is fast too, not just the edit. - HistorySnapshot is now Full | Delta; a Delta is self-inverting and moves between the undo/redo stacks without re-capturing. Commands that touch non-entity state (new layer, group, *D block, viewport) fall back to the full snapshot via per-command predicates, and a poison flag debug-asserts any mis-scope. remove_entity leaves the handle dangling in its owner block record's entity_handles (harmless on its own, a lookup miss is skipped), so a re-insert strips stale entries first — otherwise add_entity would list the handle twice and the DXF/DWG writer would emit the entity twice. Round-trip tests cover transform/erase/add/copy (do->undo->redo equality, handle preservation, no duplication). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 11:11:17 +03:00
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
}
CmdResult::CommitEntities(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 delta_safe = entities
.iter()
.all(|entity| self.delta_add_safe(i, entity));
let pending = self.begin_undo(i, label, entities.len(), delta_safe);
for entity in entities {
self.commit_entity(entity);
}
self.tabs[i].dirty = true;
let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt());
if let Some(p) = prompt {
self.command_line.push_info(&p);
}
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
}
CmdResult::CommitEntitiesAndExit(entities) => {
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);
for entity in entities {
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(
"MVIEW Object: select a closed paper-space circle, ellipse, or polyline.",
);
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
2026-08-02 22:09:25 +03:00
.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
2026-08-02 22:09:25 +03:00
.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) => {
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 {
return self.apply_cmd_result(CmdResult::CommitAndExit(wipeout));
}
self.command_line.push_error(
"WIPEOUT Polyline: select a closed planar polyline with at least 3 vertices.",
);
if let Some(prompt) =
self.tabs[i].active_cmd.as_ref().map(|command| command.prompt())
{
self.command_line.push_info(&prompt);
}
}
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");
perf(undo): record entity deltas instead of cloning the whole document Frequent entity-only edits (MOVE/ROTATE/SCALE/MIRROR/ALIGN, COPY/ARRAY, draw-add, ERASE) captured a full ~800k-entity CadDocument clone (~46ms) on every edit — the last big per-edit cost after the incremental-render work. They now record only the touched entities' before/after images: - Scene self-records through the five mutation primitives when a recording is open (UndoRecording + is_recording_undo guard, zero cost off-path). - apply_entity_delta installs one side per handle (in-place / re-insert with the original handle / remove) plus a per-handle cache reseed, then a single bump_entities so the incremental caches patch instead of a full re-tess — undo/redo of a small edit is fast too, not just the edit. - HistorySnapshot is now Full | Delta; a Delta is self-inverting and moves between the undo/redo stacks without re-capturing. Commands that touch non-entity state (new layer, group, *D block, viewport) fall back to the full snapshot via per-command predicates, and a poison flag debug-asserts any mis-scope. remove_entity leaves the handle dangling in its owner block record's entity_handles (harmless on its own, a lookup miss is skipped), so a re-insert strips stale entries first — otherwise add_entity would list the handle twice and the DXF/DWG writer would emit the entity twice. Round-trip tests cover transform/erase/add/copy (do->undo->redo equality, handle preservation, no duplication). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 11:11:17 +03:00
// 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();
perf(undo): record entity deltas instead of cloning the whole document Frequent entity-only edits (MOVE/ROTATE/SCALE/MIRROR/ALIGN, COPY/ARRAY, draw-add, ERASE) captured a full ~800k-entity CadDocument clone (~46ms) on every edit — the last big per-edit cost after the incremental-render work. They now record only the touched entities' before/after images: - Scene self-records through the five mutation primitives when a recording is open (UndoRecording + is_recording_undo guard, zero cost off-path). - apply_entity_delta installs one side per handle (in-place / re-insert with the original handle / remove) plus a per-handle cache reseed, then a single bump_entities so the incremental caches patch instead of a full re-tess — undo/redo of a small edit is fast too, not just the edit. - HistorySnapshot is now Full | Delta; a Delta is self-inverting and moves between the undo/redo stacks without re-capturing. Commands that touch non-entity state (new layer, group, *D block, viewport) fall back to the full snapshot via per-command predicates, and a poison flag debug-asserts any mis-scope. remove_entity leaves the handle dangling in its owner block record's entity_handles (harmless on its own, a lookup miss is skipped), so a re-insert strips stale entries first — otherwise add_entity would list the handle twice and the DXF/DWG writer would emit the entity twice. Round-trip tests cover transform/erase/add/copy (do->undo->redo equality, handle preservation, no duplication). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 11:11:17 +03:00
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");
perf(undo): record entity deltas instead of cloning the whole document Frequent entity-only edits (MOVE/ROTATE/SCALE/MIRROR/ALIGN, COPY/ARRAY, draw-add, ERASE) captured a full ~800k-entity CadDocument clone (~46ms) on every edit — the last big per-edit cost after the incremental-render work. They now record only the touched entities' before/after images: - Scene self-records through the five mutation primitives when a recording is open (UndoRecording + is_recording_undo guard, zero cost off-path). - apply_entity_delta installs one side per handle (in-place / re-insert with the original handle / remove) plus a per-handle cache reseed, then a single bump_entities so the incremental caches patch instead of a full re-tess — undo/redo of a small edit is fast too, not just the edit. - HistorySnapshot is now Full | Delta; a Delta is self-inverting and moves between the undo/redo stacks without re-capturing. Commands that touch non-entity state (new layer, group, *D block, viewport) fall back to the full snapshot via per-command predicates, and a poison flag debug-asserts any mis-scope. remove_entity leaves the handle dangling in its owner block record's entity_handles (harmless on its own, a lookup miss is skipped), so a re-insert strips stale entries first — otherwise add_entity would list the handle twice and the DXF/DWG writer would emit the entity twice. Round-trip tests cover transform/erase/add/copy (do->undo->redo equality, handle preservation, no duplication). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 11:11:17 +03:00
// 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();
perf(undo): record entity deltas instead of cloning the whole document Frequent entity-only edits (MOVE/ROTATE/SCALE/MIRROR/ALIGN, COPY/ARRAY, draw-add, ERASE) captured a full ~800k-entity CadDocument clone (~46ms) on every edit — the last big per-edit cost after the incremental-render work. They now record only the touched entities' before/after images: - Scene self-records through the five mutation primitives when a recording is open (UndoRecording + is_recording_undo guard, zero cost off-path). - apply_entity_delta installs one side per handle (in-place / re-insert with the original handle / remove) plus a per-handle cache reseed, then a single bump_entities so the incremental caches patch instead of a full re-tess — undo/redo of a small edit is fast too, not just the edit. - HistorySnapshot is now Full | Delta; a Delta is self-inverting and moves between the undo/redo stacks without re-capturing. Commands that touch non-entity state (new layer, group, *D block, viewport) fall back to the full snapshot via per-command predicates, and a poison flag debug-asserts any mis-scope. remove_entity leaves the handle dangling in its owner block record's entity_handles (harmless on its own, a lookup miss is skipped), so a re-insert strips stale entries first — otherwise add_entity would list the handle twice and the DXF/DWG writer would emit the entity twice. Round-trip tests cover transform/erase/add/copy (do->undo->redo equality, handle preservation, no duplication). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 11:11:17 +03:00
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
}
CmdResult::CommitAndExit(entity) => {
// For XATTACH: ensure the xref block definition exists before
// committing the INSERT entity that references it.
// Extract path early to avoid borrow conflicts.
let xattach_path: Option<String> = {
let tab = &self.tabs[i];
if let Some(cmd) = tab.active_cmd.as_ref() {
if cmd.name() == "XATTACH" {
cmd.xattach_path()
} else {
None
}
} else {
None
}
};
if let Some(path) = xattach_path {
crate::modules::insert::xattach::prepare_xref_block(
&mut self.tabs[i].scene,
&path,
);
// Resolving the xref merged its layer / linetype tables
// into the document — mirror them into the Layers panel
// and ribbon dropdowns now, not on the next reopen (#407).
self.refresh_layer_panel();
}
// Record where this draw ended so ARC_CONT can continue from it
// (before `entity` is moved into commit_entity).
self.update_cont_anchor(&entity);
let label = self.history_label_from_active_cmd(i, "ENTITY");
perf(undo): record entity deltas instead of cloning the whole document Frequent entity-only edits (MOVE/ROTATE/SCALE/MIRROR/ALIGN, COPY/ARRAY, draw-add, ERASE) captured a full ~800k-entity CadDocument clone (~46ms) on every edit — the last big per-edit cost after the incremental-render work. They now record only the touched entities' before/after images: - Scene self-records through the five mutation primitives when a recording is open (UndoRecording + is_recording_undo guard, zero cost off-path). - apply_entity_delta installs one side per handle (in-place / re-insert with the original handle / remove) plus a per-handle cache reseed, then a single bump_entities so the incremental caches patch instead of a full re-tess — undo/redo of a small edit is fast too, not just the edit. - HistorySnapshot is now Full | Delta; a Delta is self-inverting and moves between the undo/redo stacks without re-capturing. Commands that touch non-entity state (new layer, group, *D block, viewport) fall back to the full snapshot via per-command predicates, and a poison flag debug-asserts any mis-scope. remove_entity leaves the handle dangling in its owner block record's entity_handles (harmless on its own, a lookup miss is skipped), so a re-insert strips stale entries first — otherwise add_entity would list the handle twice and the DXF/DWG writer would emit the entity twice. Round-trip tests cover transform/erase/add/copy (do->undo->redo equality, handle preservation, no duplication). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 11:11:17 +03:00
let delta_safe = self.delta_add_safe(i, &entity);
let pending = self.begin_undo(i, label, 1, delta_safe);
self.commit_entity(entity);
self.tabs[i].dirty = true;
self.tabs[i].scene.clear_preview_wire();
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.restore_pre_cmd_tangent();
perf(undo): record entity deltas instead of cloning the whole document Frequent entity-only edits (MOVE/ROTATE/SCALE/MIRROR/ALIGN, COPY/ARRAY, draw-add, ERASE) captured a full ~800k-entity CadDocument clone (~46ms) on every edit — the last big per-edit cost after the incremental-render work. They now record only the touched entities' before/after images: - Scene self-records through the five mutation primitives when a recording is open (UndoRecording + is_recording_undo guard, zero cost off-path). - apply_entity_delta installs one side per handle (in-place / re-insert with the original handle / remove) plus a per-handle cache reseed, then a single bump_entities so the incremental caches patch instead of a full re-tess — undo/redo of a small edit is fast too, not just the edit. - HistorySnapshot is now Full | Delta; a Delta is self-inverting and moves between the undo/redo stacks without re-capturing. Commands that touch non-entity state (new layer, group, *D block, viewport) fall back to the full snapshot via per-command predicates, and a poison flag debug-asserts any mis-scope. remove_entity leaves the handle dangling in its owner block record's entity_handles (harmless on its own, a lookup miss is skipped), so a re-insert strips stale entries first — otherwise add_entity would list the handle twice and the DXF/DWG writer would emit the entity twice. Round-trip tests cover transform/erase/add/copy (do->undo->redo equality, handle preservation, no duplication). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 11:11:17 +03:00
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
}
CmdResult::CommitSolid {
entity,
solid,
history,
} => {
let label = self.history_label_from_active_cmd(i, "SOLID");
let pending = self.begin_undo(i, label, 1, true);
self.add_solid_model(entity, *solid, history);
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::CommitAndEditText(entity) => {
let label = self.history_label_from_active_cmd(i, "ENTITY");
perf(undo): record entity deltas instead of cloning the whole document Frequent entity-only edits (MOVE/ROTATE/SCALE/MIRROR/ALIGN, COPY/ARRAY, draw-add, ERASE) captured a full ~800k-entity CadDocument clone (~46ms) on every edit — the last big per-edit cost after the incremental-render work. They now record only the touched entities' before/after images: - Scene self-records through the five mutation primitives when a recording is open (UndoRecording + is_recording_undo guard, zero cost off-path). - apply_entity_delta installs one side per handle (in-place / re-insert with the original handle / remove) plus a per-handle cache reseed, then a single bump_entities so the incremental caches patch instead of a full re-tess — undo/redo of a small edit is fast too, not just the edit. - HistorySnapshot is now Full | Delta; a Delta is self-inverting and moves between the undo/redo stacks without re-capturing. Commands that touch non-entity state (new layer, group, *D block, viewport) fall back to the full snapshot via per-command predicates, and a poison flag debug-asserts any mis-scope. remove_entity leaves the handle dangling in its owner block record's entity_handles (harmless on its own, a lookup miss is skipped), so a re-insert strips stale entries first — otherwise add_entity would list the handle twice and the DXF/DWG writer would emit the entity twice. Round-trip tests cover transform/erase/add/copy (do->undo->redo equality, handle preservation, no duplication). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 11:11:17 +03:00
let delta_safe = self.delta_add_safe(i, &entity);
let pending = self.begin_undo(i, label, 1, delta_safe);
let handle = self.commit_entity_handle(entity);
self.tabs[i].dirty = true;
self.tabs[i].scene.clear_preview_wire();
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.restore_pre_cmd_tangent();
self.ribbon.deactivate_tool();
perf(undo): record entity deltas instead of cloning the whole document Frequent entity-only edits (MOVE/ROTATE/SCALE/MIRROR/ALIGN, COPY/ARRAY, draw-add, ERASE) captured a full ~800k-entity CadDocument clone (~46ms) on every edit — the last big per-edit cost after the incremental-render work. They now record only the touched entities' before/after images: - Scene self-records through the five mutation primitives when a recording is open (UndoRecording + is_recording_undo guard, zero cost off-path). - apply_entity_delta installs one side per handle (in-place / re-insert with the original handle / remove) plus a per-handle cache reseed, then a single bump_entities so the incremental caches patch instead of a full re-tess — undo/redo of a small edit is fast too, not just the edit. - HistorySnapshot is now Full | Delta; a Delta is self-inverting and moves between the undo/redo stacks without re-capturing. Commands that touch non-entity state (new layer, group, *D block, viewport) fall back to the full snapshot via per-command predicates, and a poison flag debug-asserts any mis-scope. remove_entity leaves the handle dangling in its owner block record's entity_handles (harmless on its own, a lookup miss is skipped), so a re-insert strips stale entries first — otherwise add_entity would list the handle twice and the DXF/DWG writer would emit the entity twice. Round-trip tests cover transform/erase/add/copy (do->undo->redo equality, handle preservation, no duplication). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 11:11:17 +03:00
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
if let Some(h) = handle {
return self.begin_text_edit(h);
}
}
CmdResult::CommitManyAndEditText {
entities,
edit_index,
} => {
let label = self.history_label_from_active_cmd(i, "ENTITY");
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 let Some(h) = edit_handle {
return self.begin_text_edit(h);
}
}
2026-05-12 10:36:15 +03:00
CmdResult::CreateBlock {
mut handles,
2026-05-12 10:36:15 +03:00
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);
2026-05-12 10:36:15 +03:00
match self.tabs[i]
.scene
.create_block_from_entities(
&handles,
&name,
&world_to_block,
&block_to_world,
)
2026-05-12 10:36:15 +03:00
{
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
2026-08-02 22:09:25 +03:00
.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);
}
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::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();
perf(undo): record entity deltas instead of cloning the whole document Frequent entity-only edits (MOVE/ROTATE/SCALE/MIRROR/ALIGN, COPY/ARRAY, draw-add, ERASE) captured a full ~800k-entity CadDocument clone (~46ms) on every edit — the last big per-edit cost after the incremental-render work. They now record only the touched entities' before/after images: - Scene self-records through the five mutation primitives when a recording is open (UndoRecording + is_recording_undo guard, zero cost off-path). - apply_entity_delta installs one side per handle (in-place / re-insert with the original handle / remove) plus a per-handle cache reseed, then a single bump_entities so the incremental caches patch instead of a full re-tess — undo/redo of a small edit is fast too, not just the edit. - HistorySnapshot is now Full | Delta; a Delta is self-inverting and moves between the undo/redo stacks without re-capturing. Commands that touch non-entity state (new layer, group, *D block, viewport) fall back to the full snapshot via per-command predicates, and a poison flag debug-asserts any mis-scope. remove_entity leaves the handle dangling in its owner block record's entity_handles (harmless on its own, a lookup miss is skipped), so a re-insert strips stale entries first — otherwise add_entity would list the handle twice and the DXF/DWG writer would emit the entity twice. Round-trip tests cover transform/erase/add/copy (do->undo->redo equality, handle preservation, no duplication). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 11:11:17 +03:00
// 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
2026-08-02 22:09:25 +03:00
.push_output(crate::tf!("{label}: {count} {noun} created.").as_ref());
self.refresh_properties();
perf(undo): record entity deltas instead of cloning the whole document Frequent entity-only edits (MOVE/ROTATE/SCALE/MIRROR/ALIGN, COPY/ARRAY, draw-add, ERASE) captured a full ~800k-entity CadDocument clone (~46ms) on every edit — the last big per-edit cost after the incremental-render work. They now record only the touched entities' before/after images: - Scene self-records through the five mutation primitives when a recording is open (UndoRecording + is_recording_undo guard, zero cost off-path). - apply_entity_delta installs one side per handle (in-place / re-insert with the original handle / remove) plus a per-handle cache reseed, then a single bump_entities so the incremental caches patch instead of a full re-tess — undo/redo of a small edit is fast too, not just the edit. - HistorySnapshot is now Full | Delta; a Delta is self-inverting and moves between the undo/redo stacks without re-capturing. Commands that touch non-entity state (new layer, group, *D block, viewport) fall back to the full snapshot via per-command predicates, and a poison flag debug-asserts any mis-scope. remove_entity leaves the handle dangling in its owner block record's entity_handles (harmless on its own, a lookup miss is skipped), so a re-insert strips stale entries first — otherwise add_entity would list the handle twice and the DXF/DWG writer would emit the entity twice. Round-trip tests cover transform/erase/add/copy (do->undo->redo equality, handle preservation, no duplication). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 11:11:17 +03:00
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.tabs[i].scene.erase_entities(&[handle]);
for entity in entities {
let nh = self.tabs[i].scene.add_entity(entity);
// Drop a stale *D block on any replaced dimension (#181).
if matches!(
self.tabs[i].scene.document.get_entity(nh),
Some(acadrust::EntityType::Dimension(_))
) {
self.tabs[i].scene.invalidate_dim_block_recorded(nh);
}
}
}
for entity in additions {
self.tabs[i].scene.add_entity(entity);
}
self.tabs[i].dirty = true;
self.tabs[i].scene.clear_preview_wire();
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
if was_catchment {
self.command_line
2026-08-02 22:09:25 +03:00
.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 {
self.tabs[i].scene.erase_entities(&[handle]);
let new_handles: Vec<Handle> = entities
.into_iter()
.map(|entity| self.tabs[i].scene.add_entity(entity))
.collect();
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::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());
2026-05-12 10:36:15 +03:00
if let Some(p) = prompt {
self.command_line.push_info(&p);
}
return Task::none();
}
}
}
// Detect DIMBREAK sentinel.
if new_entities.len() == 1 {
if let acadrust::EntityType::XLine(ref xl) = new_entities[0] {
let layer = xl.common.layer.clone();
2026-05-12 10:36:15 +03:00
if layer.starts_with("__DIMBREAK__")
|| layer.starts_with("__DIMBREAK_AUTO__")
{
// DIMBREAK needs a break-gap field on the dimension
// model (not yet present) to store and render the gap.
// Report honestly rather than claiming success while
// changing nothing. (#181 / DIM-020)
self.command_line
2026-08-02 22:09:25 +03:00
.push_info(crate::t!("DIMBREAK: not yet implemented — nothing changed.").as_ref());
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
return Task::none();
}
if layer.starts_with("__DIMSPACE__") {
if let Some(encoded) = layer.strip_prefix("__DIMSPACE__") {
apply_dimspace(&mut self.tabs[i].scene, encoded);
}
self.push_undo_snapshot(i, "DIMSPACE");
2026-08-02 22:09:25 +03:00
self.command_line.push_output(crate::t!("DIMSPACE Spacing adjusted.").as_ref());
self.tabs[i].dirty = true;
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
return Task::none();
}
if layer.starts_with("__DIMJOG__") {
// DIMJOGLINE needs a jog-point field on the dimension
// model (not yet present) to store and render the jog.
// Report honestly rather than faking success. (DIM-019)
self.command_line
2026-08-02 22:09:25 +03:00
.push_info(crate::t!("DIMJOGLINE: not yet implemented — nothing changed.").as_ref());
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
return Task::none();
}
if layer.starts_with("__MLEADERALIGN__") {
if let Some(encoded) = layer.strip_prefix("__MLEADERALIGN__") {
apply_mleader_align(&mut self.tabs[i].scene, encoded);
}
self.push_undo_snapshot(i, "MLEADERALIGN");
2026-05-12 10:36:15 +03:00
self.command_line
2026-08-02 22:09:25 +03:00
.push_output(crate::t!("MLEADERALIGN Leaders aligned.").as_ref());
self.tabs[i].dirty = true;
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
return Task::none();
}
if layer.starts_with("__MLEADERCOLLECT__") {
if let Some(encoded) = layer.strip_prefix("__MLEADERCOLLECT__") {
apply_mleader_collect(&mut self.tabs[i].scene, encoded);
}
self.push_undo_snapshot(i, "MLEADERCOLLECT");
2026-05-12 10:36:15 +03:00
self.command_line
2026-08-02 22:09:25 +03:00
.push_output(crate::t!("MLEADERCOLLECT Leaders collected.").as_ref());
self.tabs[i].dirty = true;
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
return Task::none();
}
}
}
let label = self.history_label_from_active_cmd(i, "TRIM");
self.push_undo_snapshot(i, label);
self.tabs[i].scene.erase_entities(&[handle]);
let new_handles: Vec<acadrust::Handle> = new_entities
.into_iter()
.map(|e| self.tabs[i].scene.add_entity(e))
.collect();
// A replaced dimension carries edited geometry/text but still
// names its old *D block; drop that stale block so the next save
// re-bakes it — otherwise BricsCAD/ODA draw the pre-edit
// graphics while OCS shows the edit. (#181)
for &nh in &new_handles {
if matches!(
self.tabs[i].scene.document.get_entity(nh),
Some(acadrust::EntityType::Dimension(_))
) {
self.tabs[i].scene.invalidate_dim_block_recorded(nh);
}
}
if let Some(cmd) = &mut self.tabs[i].active_cmd {
cmd.on_entity_replaced(handle, &new_handles);
}
self.tabs[i].dirty = true;
let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt());
if let Some(p) = prompt {
self.command_line.push_info(&p);
}
}
CmdResult::AttreqNeeded { block_name } => {
// Collect the full AttributeDefinitions owned by this block
// record so each created attribute keeps its geometry (#255).
let attdefs: Vec<acadrust::entities::AttributeDefinition> = {
let doc = &self.tabs[i].scene.document;
if let Some(br) = doc.block_records.get(&block_name) {
2026-05-12 10:36:15 +03:00
br.entity_handles
.iter()
.filter_map(|&h| {
if let Some(acadrust::EntityType::AttributeDefinition(ad)) =
doc.get_entity(h)
{
Some(ad.clone())
2026-05-12 10:36:15 +03:00
} else {
None
}
})
.collect()
} else {
vec![]
}
};
if attdefs.is_empty() {
// No attribute definitions — commit the INSERT directly.
2026-05-12 10:36:15 +03:00
let entity = self.tabs[i]
.active_cmd
.as_mut()
.and_then(|c| c.attreq_take_insert());
if let Some(entity) = entity {
let label = self.history_label_from_active_cmd(i, "INSERT");
let delta_safe = self.delta_add_safe(i, &entity);
let pending = self.begin_undo(i, label, 1, delta_safe);
self.commit_entity(entity);
self.tabs[i].dirty = true;
self.tabs[i].scene.clear_preview_wire();
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.restore_pre_cmd_tangent();
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
}
} else {
// Inject attdefs so the command enters attr-filling mode.
if let Some(cmd) = &mut self.tabs[i].active_cmd {
cmd.attreq_set_attdefs(attdefs);
}
let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt());
if let Some(p) = prompt {
self.command_line.push_info(&p);
}
}
}
CmdResult::CommitLiveEntity(entity) => {
let label = self.history_label_from_active_cmd(i, "ENTITY");
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();
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.restore_pre_cmd_tangent();
self.command_line.push_info(if space_changed {
"Command cancelled because the active drawing space changed."
} else {
"Command cancelled."
});
}
feat(select): offer the selection modes at "Select objects" Only Previous and Last were on offer, so Window, Crossing, Fence, the two polygons and All could not be reached at all -- and with no keyboard to type them into, the buttons were the only way in. Window and Crossing fix the sense of the next box. Dragging derives that from the direction the corner travels and would overwrite it the moment the corner moved, so the derivation stands down while a keyword holds it. Add and Remove decide what a pick does for the rest of the round: Remove subtracts the way Shift already does, rather than through PICKADD, which decides whether a pick *replaces* the set and so would have wiped everything gathered so far. Both are dropped when the next command starts, since neither outlives the selection that asked for them. Fence and the polygons are picked point by point, which TRIM and EXTEND already ask for. That gesture -- the points, the rubber-banded dashed preview, whether the path closes -- moves into one place both bind to, and the two preview builders duplicated in trim.rs go with it. A fence is not the polygon test with crossing set: that closes the point list and counts whatever lies inside, so a line drawn past a row of objects would take everything behind it too. It gets a hit test that only counts a real intersection, and skips the area-based geometry entirely. The lasso gathered its hits from wires, hatches, block hatches, meshes and block meshes inline; the new modes need the same list, and a second copy would drift. Both now come through `Scene::path_hit_handles`. Closes #596 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 13:32:11 +03:00
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::MatchEntityLayer { dest, src } => {
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.tabs[i].scene.clear_preview_wire();
2026-05-12 10:36:15 +03:00
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;
2026-05-12 10:36:15 +03:00
self.command_line
2026-08-02 22:09:25 +03:00
.push_info(crate::tf!("Layer matched to \"{layer}\".").as_ref());
self.sync_ribbon_layers();
} else {
2026-08-02 22:09:25 +03:00
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).
// Special (type-specific) properties travel like AutoCAD's
// Special Properties: each is captured from the source when it
// carries it and applied only to destinations that support it
// (#281). Text formatting crosses TEXT ↔ MTEXT (#361); the dim
// style crosses Dimension / Leader / Tolerance.
let src_clone = self.tabs[i].scene.document.get_entity(src).cloned();
let 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.push_undo_snapshot(i, "MATCHPROP");
for h in &dest {
let mut is_dim = false;
let mut is_hatch = false;
if let Some(e) = self.tabs[i].scene.document.get_entity_mut(*h) {
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_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 self.tabs[i].scene.document,
*h,
"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!(
self.tabs[i].scene.document.get_entity(*h),
Some(
acadrust::EntityType::Dimension(_)
| acadrust::EntityType::Leader(_)
)
)
{
if matches!(
self.tabs[i].scene.document.get_entity(*h),
Some(
acadrust::EntityType::Dimension(_)
| acadrust::EntityType::Leader(_)
)
) && matches!(
src_clone,
Some(
acadrust::EntityType::Dimension(_)
| acadrust::EntityType::Leader(_)
)
) {
crate::entities::dim_override::replace(
&mut self.tabs[i].scene.document,
*h,
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 {
self.tabs[i].scene.invalidate_dim_block_recorded(*h);
}
// Hatch fills render from a prebuilt model (#415).
self.tabs[i].scene.refresh_fill_model(*h);
}
self.tabs[i].dirty = true;
// Color / linetype / lineweight are baked into the cached
// wires at tessellation time; 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.refresh_properties();
2026-05-12 10:36:15 +03:00
self.command_line
2026-08-02 22:09:25 +03:00
.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 {
2026-08-02 22:09:25 +03:00
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() {
2026-08-02 22:09:25 +03:00
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();
2026-05-12 10:36:15 +03:00
self.command_line
2026-08-02 22:09:25 +03:00
.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();
}
2026-07-25 18:49:18 +03:00
let undo = self.begin_group_undo(i, "GROUP");
self.tabs[i].scene.create_group(name.clone(), handles);
self.tabs[i].dirty = true;
2026-07-25 18:49:18 +03:00
self.commit_group_undo(i, undo);
2026-05-12 10:36:15 +03:00
self.command_line
2026-08-02 22:09:25 +03:00
.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();
}
2026-07-25 18:49:18 +03:00
let undo = self.begin_group_undo(i, "UNGROUP");
let count = self.tabs[i].scene.delete_groups_containing(&handles);
self.tabs[i].dirty = true;
2026-07-25 18:49:18 +03:00
self.commit_group_undo(i, undo);
if count > 0 {
2026-05-12 10:36:15 +03:00
self.command_line
2026-08-02 22:09:25 +03:00
.push_info(crate::tf!("{} group(s) dissolved.", count).as_ref());
} else {
2026-05-12 10:36:15 +03:00
self.command_line
2026-08-02 22:09:25 +03:00
.push_info(crate::t!("No groups found for selected objects.").as_ref());
}
}
2026-05-12 10:36:15 +03:00
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".
2026-05-12 10:36:15 +03:00
let freeze_handles: Vec<Handle> = freeze
.iter()
.filter_map(|name| {
2026-05-12 10:36:15 +03:00
self.tabs[i]
.scene
.document
.layers
.iter()
.find(|l| l.name.eq_ignore_ascii_case(name))
.map(|l| l.handle)
})
.collect();
2026-05-12 10:36:15 +03:00
let thaw_handles: Vec<Handle> = thaw
.iter()
.filter_map(|name| {
2026-05-12 10:36:15 +03:00
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();
2026-05-12 10:36:15 +03:00
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);
2026-05-12 10:36:15 +03:00
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 {
2026-08-02 22:09:25 +03:00
self.command_line.push_info(crate::tf!(
2026-05-12 10:36:15 +03:00
"VPLAYER: {frozen_count} layer(s) frozen in viewport."
2026-08-02 22:09:25 +03:00
).as_ref());
}
if thawed_count > 0 {
2026-08-02 22:09:25 +03:00
self.command_line.push_info(crate::tf!(
2026-05-12 10:36:15 +03:00
"VPLAYER: {thawed_count} layer(s) thawed in viewport."
2026-08-02 22:09:25 +03:00
).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();
2026-05-12 10:36:15 +03:00
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());
2026-08-02 22:09:25 +03:00
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::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);
}
}
2026-05-12 10:36:15 +03:00
CmdResult::AlignSelected {
mut handles,
2026-05-12 10:36:15 +03:00
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");
perf(undo): record entity deltas instead of cloning the whole document Frequent entity-only edits (MOVE/ROTATE/SCALE/MIRROR/ALIGN, COPY/ARRAY, draw-add, ERASE) captured a full ~800k-entity CadDocument clone (~46ms) on every edit — the last big per-edit cost after the incremental-render work. They now record only the touched entities' before/after images: - Scene self-records through the five mutation primitives when a recording is open (UndoRecording + is_recording_undo guard, zero cost off-path). - apply_entity_delta installs one side per handle (in-place / re-insert with the original handle / remove) plus a per-handle cache reseed, then a single bump_entities so the incremental caches patch instead of a full re-tess — undo/redo of a small edit is fast too, not just the edit. - HistorySnapshot is now Full | Delta; a Delta is self-inverting and moves between the undo/redo stacks without re-capturing. Commands that touch non-entity state (new layer, group, *D block, viewport) fall back to the full snapshot via per-command predicates, and a poison flag debug-asserts any mis-scope. remove_entity leaves the handle dangling in its owner block record's entity_handles (harmless on its own, a lookup miss is skipped), so a re-insert strips stale entries first — otherwise add_entity would list the handle twice and the DXF/DWG writer would emit the entity twice. Round-trip tests cover transform/erase/add/copy (do->undo->redo equality, handle preservation, no duplication). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 11:11:17 +03:00
// 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();
2026-08-02 22:09:25 +03:00
self.command_line.push_output(crate::t!("ALIGN: applied.").as_ref());
self.refresh_properties();
perf(undo): record entity deltas instead of cloning the whole document Frequent entity-only edits (MOVE/ROTATE/SCALE/MIRROR/ALIGN, COPY/ARRAY, draw-add, ERASE) captured a full ~800k-entity CadDocument clone (~46ms) on every edit — the last big per-edit cost after the incremental-render work. They now record only the touched entities' before/after images: - Scene self-records through the five mutation primitives when a recording is open (UndoRecording + is_recording_undo guard, zero cost off-path). - apply_entity_delta installs one side per handle (in-place / re-insert with the original handle / remove) plus a per-handle cache reseed, then a single bump_entities so the incremental caches patch instead of a full re-tess — undo/redo of a small edit is fast too, not just the edit. - HistorySnapshot is now Full | Delta; a Delta is self-inverting and moves between the undo/redo stacks without re-capturing. Commands that touch non-entity state (new layer, group, *D block, viewport) fall back to the full snapshot via per-command predicates, and a poison flag debug-asserts any mis-scope. remove_entity leaves the handle dangling in its owner block record's entity_handles (harmless on its own, a lookup miss is skipped), so a re-insert strips stale entries first — otherwise add_entity would list the handle twice and the DXF/DWG writer would emit the entity twice. Round-trip tests cover transform/erase/add/copy (do->undo->redo equality, handle preservation, no duplication). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 11:11:17 +03:00
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
}
}
2026-05-12 10:36:15 +03:00
CmdResult::LengthenEntity {
handle,
pick_pt,
mode,
} => {
if self.reject_locked_edit(i, handle) {
return Task::none();
}
use crate::modules::draw::modify::lengthen::lengthen_entity;
2026-05-12 10:36:15 +03:00
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;
2026-08-02 22:09:25 +03:00
self.command_line.push_output(crate::t!("LENGTHEN: applied.").as_ref());
self.refresh_properties();
}
None => {
2026-05-12 10:36:15 +03:00
self.command_line
2026-08-02 22:09:25 +03:00
.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 } => {
use crate::modules::draw::inquiry::divide::divide_entity;
2026-05-12 10:36:15 +03:00
let pts = self.tabs[i]
.scene
.document
.get_entity(handle)
.map(|e| divide_entity(e, n))
.unwrap_or_default();
let count = pts.len();
if count > 0 {
self.push_undo_snapshot(i, "DIVIDE");
2026-05-12 10:36:15 +03:00
for p in pts {
self.tabs[i].scene.add_entity(p);
}
self.tabs[i].dirty = true;
2026-05-12 10:36:15 +03:00
self.command_line
2026-08-02 22:09:25 +03:00
.push_output(crate::tf!("DIVIDE: {count} point(s) placed.").as_ref());
} else {
2026-05-12 10:36:15 +03:00
self.command_line
2026-08-02 22:09:25 +03:00
.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();
}
2026-05-12 10:36:15 +03:00
CmdResult::MeasureEntity {
handle,
segment_length,
} => {
use crate::modules::draw::inquiry::divide::measure_entity;
2026-05-12 10:36:15 +03:00
let pts = self.tabs[i]
.scene
.document
.get_entity(handle)
.map(|e| measure_entity(e, segment_length))
.unwrap_or_default();
let count = pts.len();
if count > 0 {
self.push_undo_snapshot(i, "MEASURE");
2026-05-12 10:36:15 +03:00
for p in pts {
self.tabs[i].scene.add_entity(p);
}
self.tabs[i].dirty = true;
2026-05-12 10:36:15 +03:00
self.command_line
2026-08-02 22:09:25 +03:00
.push_output(crate::tf!("MEASURE: {count} point(s) placed.").as_ref());
} else {
2026-05-12 10:36:15 +03:00
self.command_line
2026-08-02 22:09:25 +03:00
.push_error(crate::t!("MEASURE: entity type not supported or distance too large.").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 } => {
if self.reject_locked_edit(i, handle) {
return Task::none();
}
feat(pedit): convert prompt, join, fit/spline/decurve; join fixes (#263) PEDIT reworked around a mode machine: - Picking a line/arc asks "Object is not a polyline. Turn it into one? [Yes/No] <Y>" — Yes converts in place (arc keeps its bulge) and editing continues on the result. Pickfirst: an already-selected polyline skips the select step, a selected line/arc goes straight to the convert prompt. - Join gathers with the normal selection system (single picks AND window/crossing boxes) and merges through the JOIN machinery. - Fit replaces every segment with a classic equal-parameter BIARC: two arcs per segment meeting tangentially at an inserted knee, both segments at a vertex sharing that vertex's tangent — the whole run is tangent-continuous (verified: zero tangent jump on L/closed/zigzag/S shapes). Spline resamples the vertex frame as a uniform cubic B-spline (8 samples per span); Decurve straightens every bulge. - Earlier hardening kept: undo snapshot before the mutation, immediate repaint, polyline-only pick validation. - Vertex editing lives on grips, so no Edit-vertex submode; instead the vertex grip hover menu gains Break — a closed polyline opens at the vertex, an open one splits into two (interior vertices only), handled driver-side as an entity replacement. JOIN itself: segs_of now emits one segment per span of an OPEN polyline (bulges kept), so polylines merge with their neighbours — PEDIT Join's set always contains the target polyline and used to abort every time; plain JOIN had the same gap. JOIN also honours pickfirst: launched with two or more objects selected it joins them immediately. Closes #263 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:06:04 +03:00
use crate::modules::draw::modify::pedit::{
apply_pedit, convert_to_polyline, PeditOp,
};
match &op {
// The convert replaces the entity (new handle).
PeditOp::ConvertToPolyline => {
let converted = self.tabs[i]
.scene
.document
.get_entity(handle)
.and_then(convert_to_polyline);
match converted {
Some(pl) => {
return self
.apply_cmd_result(CmdResult::ReplaceEntity(handle, vec![pl]));
feat(pedit): convert prompt, join, fit/spline/decurve; join fixes (#263) PEDIT reworked around a mode machine: - Picking a line/arc asks "Object is not a polyline. Turn it into one? [Yes/No] <Y>" — Yes converts in place (arc keeps its bulge) and editing continues on the result. Pickfirst: an already-selected polyline skips the select step, a selected line/arc goes straight to the convert prompt. - Join gathers with the normal selection system (single picks AND window/crossing boxes) and merges through the JOIN machinery. - Fit replaces every segment with a classic equal-parameter BIARC: two arcs per segment meeting tangentially at an inserted knee, both segments at a vertex sharing that vertex's tangent — the whole run is tangent-continuous (verified: zero tangent jump on L/closed/zigzag/S shapes). Spline resamples the vertex frame as a uniform cubic B-spline (8 samples per span); Decurve straightens every bulge. - Earlier hardening kept: undo snapshot before the mutation, immediate repaint, polyline-only pick validation. - Vertex editing lives on grips, so no Edit-vertex submode; instead the vertex grip hover menu gains Break — a closed polyline opens at the vertex, an open one splits into two (interior vertices only), handled driver-side as an entity replacement. JOIN itself: segs_of now emits one segment per span of an OPEN polyline (bulges kept), so polylines merge with their neighbours — PEDIT Join's set always contains the target polyline and used to abort every time; plain JOIN had the same gap. JOIN also honours pickfirst: launched with two or more objects selected it joins them immediately. Closes #263 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:06:04 +03:00
}
None => self
.command_line
2026-08-02 22:09:25 +03:00
.push_error(crate::t!("PEDIT: cannot convert this entity.").as_ref()),
feat(pedit): convert prompt, join, fit/spline/decurve; join fixes (#263) PEDIT reworked around a mode machine: - Picking a line/arc asks "Object is not a polyline. Turn it into one? [Yes/No] <Y>" — Yes converts in place (arc keeps its bulge) and editing continues on the result. Pickfirst: an already-selected polyline skips the select step, a selected line/arc goes straight to the convert prompt. - Join gathers with the normal selection system (single picks AND window/crossing boxes) and merges through the JOIN machinery. - Fit replaces every segment with a classic equal-parameter BIARC: two arcs per segment meeting tangentially at an inserted knee, both segments at a vertex sharing that vertex's tangent — the whole run is tangent-continuous (verified: zero tangent jump on L/closed/zigzag/S shapes). Spline resamples the vertex frame as a uniform cubic B-spline (8 samples per span); Decurve straightens every bulge. - Earlier hardening kept: undo snapshot before the mutation, immediate repaint, polyline-only pick validation. - Vertex editing lives on grips, so no Edit-vertex submode; instead the vertex grip hover menu gains Break — a closed polyline opens at the vertex, an open one splits into two (interior vertices only), handled driver-side as an entity replacement. JOIN itself: segs_of now emits one segment per span of an OPEN polyline (bulges kept), so polylines merge with their neighbours — PEDIT Join's set always contains the target polyline and used to abort every time; plain JOIN had the same gap. JOIN also honours pickfirst: launched with two or more objects selected it joins them immediately. Closes #263 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:06:04 +03:00
}
}
_ => {
// Snapshot BEFORE the mutation — an after-the-fact
// snapshot records the changed state and undo no-ops.
self.push_undo_snapshot(i, "PEDIT");
let changed = self.tabs[i]
.scene
.document
.get_entity_mut(handle)
.map(|e| apply_pedit(e, &op))
.unwrap_or(false);
if changed {
self.tabs[i].dirty = true;
// Repaint immediately (wide-band fill included).
self.tabs[i].scene.refresh_fill_model(handle);
self.tabs[i]
.scene
.bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]);
2026-08-02 22:09:25 +03:00
self.command_line.push_output(crate::t!("PEDIT: applied.").as_ref());
feat(pedit): convert prompt, join, fit/spline/decurve; join fixes (#263) PEDIT reworked around a mode machine: - Picking a line/arc asks "Object is not a polyline. Turn it into one? [Yes/No] <Y>" — Yes converts in place (arc keeps its bulge) and editing continues on the result. Pickfirst: an already-selected polyline skips the select step, a selected line/arc goes straight to the convert prompt. - Join gathers with the normal selection system (single picks AND window/crossing boxes) and merges through the JOIN machinery. - Fit replaces every segment with a classic equal-parameter BIARC: two arcs per segment meeting tangentially at an inserted knee, both segments at a vertex sharing that vertex's tangent — the whole run is tangent-continuous (verified: zero tangent jump on L/closed/zigzag/S shapes). Spline resamples the vertex frame as a uniform cubic B-spline (8 samples per span); Decurve straightens every bulge. - Earlier hardening kept: undo snapshot before the mutation, immediate repaint, polyline-only pick validation. - Vertex editing lives on grips, so no Edit-vertex submode; instead the vertex grip hover menu gains Break — a closed polyline opens at the vertex, an open one splits into two (interior vertices only), handled driver-side as an entity replacement. JOIN itself: segs_of now emits one segment per span of an OPEN polyline (bulges kept), so polylines merge with their neighbours — PEDIT Join's set always contains the target polyline and used to abort every time; plain JOIN had the same gap. JOIN also honours pickfirst: launched with two or more objects selected it joins them immediately. Closes #263 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:06:04 +03:00
self.refresh_properties();
} else {
self.discard_last_undo_entry(i);
feat(pedit): convert prompt, join, fit/spline/decurve; join fixes (#263) PEDIT reworked around a mode machine: - Picking a line/arc asks "Object is not a polyline. Turn it into one? [Yes/No] <Y>" — Yes converts in place (arc keeps its bulge) and editing continues on the result. Pickfirst: an already-selected polyline skips the select step, a selected line/arc goes straight to the convert prompt. - Join gathers with the normal selection system (single picks AND window/crossing boxes) and merges through the JOIN machinery. - Fit replaces every segment with a classic equal-parameter BIARC: two arcs per segment meeting tangentially at an inserted knee, both segments at a vertex sharing that vertex's tangent — the whole run is tangent-continuous (verified: zero tangent jump on L/closed/zigzag/S shapes). Spline resamples the vertex frame as a uniform cubic B-spline (8 samples per span); Decurve straightens every bulge. - Earlier hardening kept: undo snapshot before the mutation, immediate repaint, polyline-only pick validation. - Vertex editing lives on grips, so no Edit-vertex submode; instead the vertex grip hover menu gains Break — a closed polyline opens at the vertex, an open one splits into two (interior vertices only), handled driver-side as an entity replacement. JOIN itself: segs_of now emits one segment per span of an OPEN polyline (bulges kept), so polylines merge with their neighbours — PEDIT Join's set always contains the target polyline and used to abort every time; plain JOIN had the same gap. JOIN also honours pickfirst: launched with two or more objects selected it joins them immediately. Closes #263 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:06:04 +03:00
self.command_line
2026-08-02 22:09:25 +03:00
.push_error(crate::t!("PEDIT: operation not applicable to this entity.").as_ref());
feat(pedit): convert prompt, join, fit/spline/decurve; join fixes (#263) PEDIT reworked around a mode machine: - Picking a line/arc asks "Object is not a polyline. Turn it into one? [Yes/No] <Y>" — Yes converts in place (arc keeps its bulge) and editing continues on the result. Pickfirst: an already-selected polyline skips the select step, a selected line/arc goes straight to the convert prompt. - Join gathers with the normal selection system (single picks AND window/crossing boxes) and merges through the JOIN machinery. - Fit replaces every segment with a classic equal-parameter BIARC: two arcs per segment meeting tangentially at an inserted knee, both segments at a vertex sharing that vertex's tangent — the whole run is tangent-continuous (verified: zero tangent jump on L/closed/zigzag/S shapes). Spline resamples the vertex frame as a uniform cubic B-spline (8 samples per span); Decurve straightens every bulge. - Earlier hardening kept: undo snapshot before the mutation, immediate repaint, polyline-only pick validation. - Vertex editing lives on grips, so no Edit-vertex submode; instead the vertex grip hover menu gains Break — a closed polyline opens at the vertex, an open one splits into two (interior vertices only), handled driver-side as an entity replacement. JOIN itself: segs_of now emits one segment per span of an OPEN polyline (bulges kept), so polylines merge with their neighbours — PEDIT Join's set always contains the target polyline and used to abort every time; plain JOIN had the same gap. JOIN also honours pickfirst: launched with two or more objects selected it joins them immediately. Closes #263 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:06:04 +03:00
}
}
}
let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt());
if let Some(p) = prompt {
self.command_line.push_info(&p);
}
}
CmdResult::JoinEntities(handles) => {
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;
2026-05-12 10:36:15 +03:00
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();
2026-08-02 22:09:25 +03:00
self.command_line.push_output(crate::tf!(
2026-05-12 10:36:15 +03:00
"JOIN: {count_in} object(s) joined into {count_out}."
2026-08-02 22:09:25 +03:00
).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(
"JOIN: objects don't form a single connected chain, or contain an unsupported type / tilted arc.",
);
}
}
}
CmdResult::BreakEntity { handle, p1, p2 } => {
if self.reject_locked_edit(i, handle) {
return Task::none();
}
use crate::modules::draw::modify::break_cmd::break_entity;
2026-05-12 10:36:15 +03:00
let replacement = self.tabs[i]
.scene
.document
.get_entity(handle)
.and_then(|e| break_entity(e, p1, p2));
match replacement {
Some(frags) => {
let label = self.history_label_from_active_cmd(i, "BREAK");
self.push_undo_snapshot(i, label);
self.tabs[i].scene.erase_entities(&[handle]);
let count = frags.len();
for e in frags {
self.tabs[i].scene.add_entity(e);
}
self.tabs[i].dirty = true;
self.tabs[i].scene.clear_preview_wire();
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.restore_pre_cmd_tangent();
2026-05-12 10:36:15 +03:00
self.command_line
2026-08-02 22:09:25 +03:00
.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();
2026-05-12 10:36:15 +03:00
self.command_line
2026-08-02 22:09:25 +03:00
.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
2026-08-02 22:09:25 +03:00
.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));
2026-08-02 22:09:25 +03:00
self.command_line.push_output(crate::tf!(
"Plot window: {x1:.2},{y1:.2} to {x2:.2},{y2:.2}"
2026-08-02 22:09:25 +03:00
).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(),
);
}
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));
}
2026-05-12 10:36:15 +03:00
CmdResult::StretchEntities {
mut handles,
windows,
2026-05-12 10:36:15 +03:00
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);
2026-05-12 10:36:15 +03:00
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);
2026-05-12 10:36:15 +03:00
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) {
2026-05-12 10:36:15 +03:00
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) {
2026-05-12 10:36:15 +03:00
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) {
2026-05-12 10:36:15 +03:00
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) {
2026-05-12 10:36:15 +03:00
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) {
2026-05-12 10:36:15 +03:00
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) {
2026-05-12 10:36:15 +03:00
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.definition_point);
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
}
}
2026-05-12 10:36:15 +03:00
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);
2026-05-12 10:36:15 +03:00
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();
2026-05-12 10:36:15 +03:00
self.command_line
2026-08-02 22:09:25 +03:00
.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);
}
}
// ── Solid3D creation (BOX / SPHERE / CYLINDER) ────────────────
CmdResult::CommitSolid3D { mesh_fn } => {
use crate::modules::insert::solid3d_cmds::empty_solid3d;
let pending = self.begin_undo(i, "SOLID3D", 1, true);
let entity = empty_solid3d();
let handle = self.tabs[i].scene.add_entity(entity);
if !handle.is_null() {
let name = format!("{}", handle.value());
let color = [0.6f32, 0.6, 0.8, 1.0]; // default colour; command embedded it
let _ = color; // color is captured inside mesh_fn
if let Some(mesh) = mesh_fn(name) {
let set = crate::scene::MeshLodSet::from_single(mesh);
if let Some(acadrust::EntityType::Solid3D(entity)) =
self.tabs[i].scene.document.get_entity_mut(handle)
{
let center = set.metrics.centroid;
entity.point_of_reference = acadrust::types::Vector3::new(
center[0], center[1], center[2],
);
}
self.tabs[i].scene.meshes.insert(handle, set);
}
self.tabs[i].dirty = true;
2026-08-02 22:09:25 +03:00
self.command_line.push_output(crate::t!("Solid created.").as_ref());
}
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.tabs[i].scene.clear_preview_wire();
self.restore_pre_cmd_tangent();
}
// ── EXTRUDE ────────────────────────────────────────────────────
2026-05-12 10:36:15 +03:00
CmdResult::ExtrudeEntity {
handle,
height,
color,
} => {
if self.reject_locked_edit(i, handle) {
self.tabs[i].active_cmd = None;
return Task::none();
}
2026-05-12 10:36:15 +03:00
use crate::modules::insert::solid3d_cmds::empty_solid3d;
feat(model): build and read solids with the geometry kernel Every 3-D solid OCS makes or opens now goes through cadkernel, which holds analytic surfaces — a cylinder is a cylinder record, not a spline that looks like one. That is what lets a solid built here be written back out as exact ACIS instead of a triangulation, and a facetted export is a one-way door: the next application to open the file would have no way back to the circle. The Model tab's seven primitives, the Design-group booleans, 3DROTATE, 3DMIRROR, 3DALIGN, SLICE, SECTION, POLYSOLID and PYRAMID all come from the kernel now, and `scene.solid_models` holds a `Body`. A mirrored solid needed its own care before; the kernel reverses the loops on the way through, so it comes back the right way out rather than lighting black. EXTRUDE and REVOLVE sweep the profile `entities::curve` already defines, so a circle, an arc-bulged polyline and a closed spline arrive as the same thing. A circle extrudes into four cylinder patches rather than a run of chords, and a profile turned about an axis becomes a cone, a sphere or a torus. A spline profile has no analytic side wall and is refused rather than approximated. ACIS goes both ways through the codec's own bridge: `lift` to read and `append` to write. That drops the 679-line truck conversion and the planar- only exporter, and the tessellator keeps its bespoke sampler as a fallback for whatever the kernel cannot yet express — reported rather than passed off as whole, since a mesh missing a wall looks finished. truck-shapeops is no longer used and is gone from the manifest. truck-modeling stays for now: fourteen files use it as a 3-D NURBS evaluator, which the kernel has no answer for yet, and SWEEP and LOFT have no kernel operation behind them either. The two `automation` test failures are unchanged from before this and are not caused by it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 00:32:34 +03:00
use crate::scene::model::{solid_model, sweep_model};
let entity_opt = self.tabs[i].scene.document.get_entity(handle).cloned();
if let Some(entity) = entity_opt {
feat(model): build and read solids with the geometry kernel Every 3-D solid OCS makes or opens now goes through cadkernel, which holds analytic surfaces — a cylinder is a cylinder record, not a spline that looks like one. That is what lets a solid built here be written back out as exact ACIS instead of a triangulation, and a facetted export is a one-way door: the next application to open the file would have no way back to the circle. The Model tab's seven primitives, the Design-group booleans, 3DROTATE, 3DMIRROR, 3DALIGN, SLICE, SECTION, POLYSOLID and PYRAMID all come from the kernel now, and `scene.solid_models` holds a `Body`. A mirrored solid needed its own care before; the kernel reverses the loops on the way through, so it comes back the right way out rather than lighting black. EXTRUDE and REVOLVE sweep the profile `entities::curve` already defines, so a circle, an arc-bulged polyline and a closed spline arrive as the same thing. A circle extrudes into four cylinder patches rather than a run of chords, and a profile turned about an axis becomes a cone, a sphere or a torus. A spline profile has no analytic side wall and is refused rather than approximated. ACIS goes both ways through the codec's own bridge: `lift` to read and `append` to write. That drops the 679-line truck conversion and the planar- only exporter, and the tessellator keeps its bespoke sampler as a fallback for whatever the kernel cannot yet express — reported rather than passed off as whole, since a mesh missing a wall looks finished. truck-shapeops is no longer used and is gone from the manifest. truck-modeling stays for now: fourteen files use it as a 3-D NURBS evaluator, which the kernel has no answer for yet, and SWEEP and LOFT have no kernel operation behind them either. The two `automation` test failures are unchanged from before this and are not caused by it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 00:32:34 +03:00
// The kernel sweeps the profile into analytic surfaces —
// a straight run becomes a plane and an arc a cylinder —
// so the solid saves as exact ACIS rather than facets.
let result = sweep_model::extruded(&entity, height as f64)
.and_then(|body| Some((solid_model::mesh_from_solid(&body, color)?, body)));
if let Some((mesh, solid)) = result {
let history = crate::scene::model::solid_history::extrusion_op(
&entity,
height as f64,
);
let pending = self.begin_undo(i, "EXTRUDE", 1, true);
feat(model): build and read solids with the geometry kernel Every 3-D solid OCS makes or opens now goes through cadkernel, which holds analytic surfaces — a cylinder is a cylinder record, not a spline that looks like one. That is what lets a solid built here be written back out as exact ACIS instead of a triangulation, and a facetted export is a one-way door: the next application to open the file would have no way back to the circle. The Model tab's seven primitives, the Design-group booleans, 3DROTATE, 3DMIRROR, 3DALIGN, SLICE, SECTION, POLYSOLID and PYRAMID all come from the kernel now, and `scene.solid_models` holds a `Body`. A mirrored solid needed its own care before; the kernel reverses the loops on the way through, so it comes back the right way out rather than lighting black. EXTRUDE and REVOLVE sweep the profile `entities::curve` already defines, so a circle, an arc-bulged polyline and a closed spline arrive as the same thing. A circle extrudes into four cylinder patches rather than a run of chords, and a profile turned about an axis becomes a cone, a sphere or a torus. A spline profile has no analytic side wall and is refused rather than approximated. ACIS goes both ways through the codec's own bridge: `lift` to read and `append` to write. That drops the 679-line truck conversion and the planar- only exporter, and the tessellator keeps its bespoke sampler as a fallback for whatever the kernel cannot yet express — reported rather than passed off as whole, since a mesh missing a wall looks finished. truck-shapeops is no longer used and is gone from the manifest. truck-modeling stays for now: fourteen files use it as a 3-D NURBS evaluator, which the kernel has no answer for yet, and SWEEP and LOFT have no kernel operation behind them either. The two `automation` test failures are unchanged from before this and are not caused by it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 00:32:34 +03:00
let mut s3d = empty_solid3d();
if let acadrust::EntityType::Solid3D(inner) = &mut s3d {
inner.wires = solid_model::edge_wires(&solid);
}
let new_handle = self.tabs[i].scene.add_entity(s3d);
self.tabs[i]
.scene
.create_solid_history(new_handle, history);
feat(model): build and read solids with the geometry kernel Every 3-D solid OCS makes or opens now goes through cadkernel, which holds analytic surfaces — a cylinder is a cylinder record, not a spline that looks like one. That is what lets a solid built here be written back out as exact ACIS instead of a triangulation, and a facetted export is a one-way door: the next application to open the file would have no way back to the circle. The Model tab's seven primitives, the Design-group booleans, 3DROTATE, 3DMIRROR, 3DALIGN, SLICE, SECTION, POLYSOLID and PYRAMID all come from the kernel now, and `scene.solid_models` holds a `Body`. A mirrored solid needed its own care before; the kernel reverses the loops on the way through, so it comes back the right way out rather than lighting black. EXTRUDE and REVOLVE sweep the profile `entities::curve` already defines, so a circle, an arc-bulged polyline and a closed spline arrive as the same thing. A circle extrudes into four cylinder patches rather than a run of chords, and a profile turned about an axis becomes a cone, a sphere or a torus. A spline profile has no analytic side wall and is refused rather than approximated. ACIS goes both ways through the codec's own bridge: `lift` to read and `append` to write. That drops the 679-line truck conversion and the planar- only exporter, and the tessellator keeps its bespoke sampler as a fallback for whatever the kernel cannot yet express — reported rather than passed off as whole, since a mesh missing a wall looks finished. truck-shapeops is no longer used and is gone from the manifest. truck-modeling stays for now: fourteen files use it as a 3-D NURBS evaluator, which the kernel has no answer for yet, and SWEEP and LOFT have no kernel operation behind them either. The two `automation` test failures are unchanged from before this and are not caused by it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 00:32:34 +03:00
self.tabs[i].scene.register_solid_model(new_handle, solid);
let _ = mesh;
self.tabs[i].dirty = true;
2026-08-02 22:09:25 +03:00
self.command_line.push_output(crate::t!("EXTRUDE: solid created.").as_ref());
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
} else {
2026-08-02 22:09:25 +03:00
self.command_line.push_error(crate::t!("EXTRUDE: could not build profile. Select a closed 2D entity (Circle, LwPolyline, etc.).").as_ref());
}
} else {
2026-08-02 22:09:25 +03:00
self.command_line.push_error(crate::t!("EXTRUDE: entity not found.").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();
}
// ── REVOLVE ────────────────────────────────────────────────────
2026-05-12 10:36:15 +03:00
CmdResult::RevolveEntity {
handle,
axis_start,
axis_end,
angle_deg,
color,
} => {
if self.reject_locked_edit(i, handle) {
self.tabs[i].active_cmd = None;
return Task::none();
}
2026-05-12 10:36:15 +03:00
use crate::modules::insert::solid3d_cmds::empty_solid3d;
feat(model): build and read solids with the geometry kernel Every 3-D solid OCS makes or opens now goes through cadkernel, which holds analytic surfaces — a cylinder is a cylinder record, not a spline that looks like one. That is what lets a solid built here be written back out as exact ACIS instead of a triangulation, and a facetted export is a one-way door: the next application to open the file would have no way back to the circle. The Model tab's seven primitives, the Design-group booleans, 3DROTATE, 3DMIRROR, 3DALIGN, SLICE, SECTION, POLYSOLID and PYRAMID all come from the kernel now, and `scene.solid_models` holds a `Body`. A mirrored solid needed its own care before; the kernel reverses the loops on the way through, so it comes back the right way out rather than lighting black. EXTRUDE and REVOLVE sweep the profile `entities::curve` already defines, so a circle, an arc-bulged polyline and a closed spline arrive as the same thing. A circle extrudes into four cylinder patches rather than a run of chords, and a profile turned about an axis becomes a cone, a sphere or a torus. A spline profile has no analytic side wall and is refused rather than approximated. ACIS goes both ways through the codec's own bridge: `lift` to read and `append` to write. That drops the 679-line truck conversion and the planar- only exporter, and the tessellator keeps its bespoke sampler as a fallback for whatever the kernel cannot yet express — reported rather than passed off as whole, since a mesh missing a wall looks finished. truck-shapeops is no longer used and is gone from the manifest. truck-modeling stays for now: fourteen files use it as a 3-D NURBS evaluator, which the kernel has no answer for yet, and SWEEP and LOFT have no kernel operation behind them either. The two `automation` test failures are unchanged from before this and are not caused by it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 00:32:34 +03:00
use crate::scene::model::{solid_model, sweep_model};
let entity_opt = self.tabs[i].scene.document.get_entity(handle).cloned();
if let Some(entity) = entity_opt {
feat(model): build and read solids with the geometry kernel Every 3-D solid OCS makes or opens now goes through cadkernel, which holds analytic surfaces — a cylinder is a cylinder record, not a spline that looks like one. That is what lets a solid built here be written back out as exact ACIS instead of a triangulation, and a facetted export is a one-way door: the next application to open the file would have no way back to the circle. The Model tab's seven primitives, the Design-group booleans, 3DROTATE, 3DMIRROR, 3DALIGN, SLICE, SECTION, POLYSOLID and PYRAMID all come from the kernel now, and `scene.solid_models` holds a `Body`. A mirrored solid needed its own care before; the kernel reverses the loops on the way through, so it comes back the right way out rather than lighting black. EXTRUDE and REVOLVE sweep the profile `entities::curve` already defines, so a circle, an arc-bulged polyline and a closed spline arrive as the same thing. A circle extrudes into four cylinder patches rather than a run of chords, and a profile turned about an axis becomes a cone, a sphere or a torus. A spline profile has no analytic side wall and is refused rather than approximated. ACIS goes both ways through the codec's own bridge: `lift` to read and `append` to write. That drops the 679-line truck conversion and the planar- only exporter, and the tessellator keeps its bespoke sampler as a fallback for whatever the kernel cannot yet express — reported rather than passed off as whole, since a mesh missing a wall looks finished. truck-shapeops is no longer used and is gone from the manifest. truck-modeling stays for now: fourteen files use it as a 3-D NURBS evaluator, which the kernel has no answer for yet, and SWEEP and LOFT have no kernel operation behind them either. The two `automation` test failures are unchanged from before this and are not caused by it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 00:32:34 +03:00
// A line turned about the axis sweeps into a plane, a
// cylinder or a cone, and an arc into a sphere or a
// torus, so a revolved solid keeps exact geometry too.
let result = sweep_model::revolved(
&entity,
[
axis_start.x as f64,
axis_start.y as f64,
axis_start.z as f64,
feat(model): build and read solids with the geometry kernel Every 3-D solid OCS makes or opens now goes through cadkernel, which holds analytic surfaces — a cylinder is a cylinder record, not a spline that looks like one. That is what lets a solid built here be written back out as exact ACIS instead of a triangulation, and a facetted export is a one-way door: the next application to open the file would have no way back to the circle. The Model tab's seven primitives, the Design-group booleans, 3DROTATE, 3DMIRROR, 3DALIGN, SLICE, SECTION, POLYSOLID and PYRAMID all come from the kernel now, and `scene.solid_models` holds a `Body`. A mirrored solid needed its own care before; the kernel reverses the loops on the way through, so it comes back the right way out rather than lighting black. EXTRUDE and REVOLVE sweep the profile `entities::curve` already defines, so a circle, an arc-bulged polyline and a closed spline arrive as the same thing. A circle extrudes into four cylinder patches rather than a run of chords, and a profile turned about an axis becomes a cone, a sphere or a torus. A spline profile has no analytic side wall and is refused rather than approximated. ACIS goes both ways through the codec's own bridge: `lift` to read and `append` to write. That drops the 679-line truck conversion and the planar- only exporter, and the tessellator keeps its bespoke sampler as a fallback for whatever the kernel cannot yet express — reported rather than passed off as whole, since a mesh missing a wall looks finished. truck-shapeops is no longer used and is gone from the manifest. truck-modeling stays for now: fourteen files use it as a 3-D NURBS evaluator, which the kernel has no answer for yet, and SWEEP and LOFT have no kernel operation behind them either. The two `automation` test failures are unchanged from before this and are not caused by it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 00:32:34 +03:00
],
[axis_end.x as f64, axis_end.y as f64, axis_end.z as f64],
(angle_deg as f64).to_radians(),
)
.and_then(|body| Some((solid_model::mesh_from_solid(&body, color)?, body)));
if let Some((mesh, solid)) = result {
let history = crate::scene::model::solid_history::revolve_op(
&entity,
axis_start.to_array(),
axis_end.to_array(),
(angle_deg as f64).to_radians(),
);
let pending = self.begin_undo(i, "REVOLVE", 1, true);
feat(model): build and read solids with the geometry kernel Every 3-D solid OCS makes or opens now goes through cadkernel, which holds analytic surfaces — a cylinder is a cylinder record, not a spline that looks like one. That is what lets a solid built here be written back out as exact ACIS instead of a triangulation, and a facetted export is a one-way door: the next application to open the file would have no way back to the circle. The Model tab's seven primitives, the Design-group booleans, 3DROTATE, 3DMIRROR, 3DALIGN, SLICE, SECTION, POLYSOLID and PYRAMID all come from the kernel now, and `scene.solid_models` holds a `Body`. A mirrored solid needed its own care before; the kernel reverses the loops on the way through, so it comes back the right way out rather than lighting black. EXTRUDE and REVOLVE sweep the profile `entities::curve` already defines, so a circle, an arc-bulged polyline and a closed spline arrive as the same thing. A circle extrudes into four cylinder patches rather than a run of chords, and a profile turned about an axis becomes a cone, a sphere or a torus. A spline profile has no analytic side wall and is refused rather than approximated. ACIS goes both ways through the codec's own bridge: `lift` to read and `append` to write. That drops the 679-line truck conversion and the planar- only exporter, and the tessellator keeps its bespoke sampler as a fallback for whatever the kernel cannot yet express — reported rather than passed off as whole, since a mesh missing a wall looks finished. truck-shapeops is no longer used and is gone from the manifest. truck-modeling stays for now: fourteen files use it as a 3-D NURBS evaluator, which the kernel has no answer for yet, and SWEEP and LOFT have no kernel operation behind them either. The two `automation` test failures are unchanged from before this and are not caused by it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 00:32:34 +03:00
let mut s3d = empty_solid3d();
if let acadrust::EntityType::Solid3D(inner) = &mut s3d {
inner.wires = solid_model::edge_wires(&solid);
}
let new_handle = self.tabs[i].scene.add_entity(s3d);
self.tabs[i]
.scene
.create_solid_history(new_handle, history);
feat(model): build and read solids with the geometry kernel Every 3-D solid OCS makes or opens now goes through cadkernel, which holds analytic surfaces — a cylinder is a cylinder record, not a spline that looks like one. That is what lets a solid built here be written back out as exact ACIS instead of a triangulation, and a facetted export is a one-way door: the next application to open the file would have no way back to the circle. The Model tab's seven primitives, the Design-group booleans, 3DROTATE, 3DMIRROR, 3DALIGN, SLICE, SECTION, POLYSOLID and PYRAMID all come from the kernel now, and `scene.solid_models` holds a `Body`. A mirrored solid needed its own care before; the kernel reverses the loops on the way through, so it comes back the right way out rather than lighting black. EXTRUDE and REVOLVE sweep the profile `entities::curve` already defines, so a circle, an arc-bulged polyline and a closed spline arrive as the same thing. A circle extrudes into four cylinder patches rather than a run of chords, and a profile turned about an axis becomes a cone, a sphere or a torus. A spline profile has no analytic side wall and is refused rather than approximated. ACIS goes both ways through the codec's own bridge: `lift` to read and `append` to write. That drops the 679-line truck conversion and the planar- only exporter, and the tessellator keeps its bespoke sampler as a fallback for whatever the kernel cannot yet express — reported rather than passed off as whole, since a mesh missing a wall looks finished. truck-shapeops is no longer used and is gone from the manifest. truck-modeling stays for now: fourteen files use it as a 3-D NURBS evaluator, which the kernel has no answer for yet, and SWEEP and LOFT have no kernel operation behind them either. The two `automation` test failures are unchanged from before this and are not caused by it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 00:32:34 +03:00
self.tabs[i].scene.register_solid_model(new_handle, solid);
let _ = mesh;
self.tabs[i].dirty = true;
2026-05-12 10:36:15 +03:00
self.command_line
2026-08-02 22:09:25 +03:00
.push_output(crate::tf!("REVOLVE: solid created ({:.0}°).", angle_deg).as_ref());
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
} else {
2026-05-12 10:36:15 +03:00
self.command_line
2026-08-02 22:09:25 +03:00
.push_error(crate::t!("REVOLVE: could not revolve profile.").as_ref());
}
} else {
feat(model): build and read solids with the geometry kernel Every 3-D solid OCS makes or opens now goes through cadkernel, which holds analytic surfaces — a cylinder is a cylinder record, not a spline that looks like one. That is what lets a solid built here be written back out as exact ACIS instead of a triangulation, and a facetted export is a one-way door: the next application to open the file would have no way back to the circle. The Model tab's seven primitives, the Design-group booleans, 3DROTATE, 3DMIRROR, 3DALIGN, SLICE, SECTION, POLYSOLID and PYRAMID all come from the kernel now, and `scene.solid_models` holds a `Body`. A mirrored solid needed its own care before; the kernel reverses the loops on the way through, so it comes back the right way out rather than lighting black. EXTRUDE and REVOLVE sweep the profile `entities::curve` already defines, so a circle, an arc-bulged polyline and a closed spline arrive as the same thing. A circle extrudes into four cylinder patches rather than a run of chords, and a profile turned about an axis becomes a cone, a sphere or a torus. A spline profile has no analytic side wall and is refused rather than approximated. ACIS goes both ways through the codec's own bridge: `lift` to read and `append` to write. That drops the 679-line truck conversion and the planar- only exporter, and the tessellator keeps its bespoke sampler as a fallback for whatever the kernel cannot yet express — reported rather than passed off as whole, since a mesh missing a wall looks finished. truck-shapeops is no longer used and is gone from the manifest. truck-modeling stays for now: fourteen files use it as a 3-D NURBS evaluator, which the kernel has no answer for yet, and SWEEP and LOFT have no kernel operation behind them either. The two `automation` test failures are unchanged from before this and are not caused by it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 00:32:34 +03:00
self.command_line
.push_error(crate::t!("REVOLVE: entity not found.").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();
}
refactor: drop truck, and evaluate every curve through the kernel The last four truck crates are gone from the manifest. What they were doing falls into three parts, and each now has one answer instead of two. NURBS evaluation. truck was carrying B-spline curves and surfaces for the SPLINE entity, hatch boundaries, BLEND's endpoint frames, the spline preview and ACIS spline-surface faces. The kernel now has both, over one de Boor written across however many coordinates a control point holds — so a plane curve and a space curve cannot drift apart, and a surface is the same algorithm applied twice. The rational and the polynomial cases stop being separate types: weights absent means polynomial. The entity conversion. Every LINE, ARC, ELLIPSE, SPLINE and polyline was built into truck topology purely so it could be sampled back into points. They are sampled through `entities::curve` now, which is where each one's geometry is already defined once and what EXTRUDE and REVOLVE read — so a circle drawn on screen and a circle handed to the Model tab come from the same definition. That retires four of TruckObject's variants and the module is renamed for what it does. SWEEP and LOFT. Both only ever produced a mesh, so both are built from point lists: a band of quads per span, and a lid where a profile closes. Lofting profiles of different densities resamples them by distance rather than by index, so a circle lofted to a square no longer twists. Two things worth noting for anyone reading the old comments. The tolerance globals that lived in the tessellation module were never about truck at all — they are the per-frame chord height, and they move to `curve_tol`. And the rule that a profile handed over as `Lines` silently broke EXTRUDE and REVOLVE no longer holds: those read the curve directly and never look at this channel. The two `automation` test failures are unchanged from before this and are not caused by it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 01:08:50 +03:00
// ── SWEEP ─────────────────────────────────────────────────────
2026-05-12 10:36:15 +03:00
CmdResult::SweepEntity {
profile_handle,
path_handle,
color,
} => {
if self.reject_locked_edit(i, profile_handle)
|| self.reject_locked_edit(i, path_handle)
{
self.tabs[i].active_cmd = None;
return Task::none();
}
2026-05-12 10:36:15 +03:00
use crate::modules::insert::solid3d_cmds::empty_solid3d;
refactor: drop truck, and evaluate every curve through the kernel The last four truck crates are gone from the manifest. What they were doing falls into three parts, and each now has one answer instead of two. NURBS evaluation. truck was carrying B-spline curves and surfaces for the SPLINE entity, hatch boundaries, BLEND's endpoint frames, the spline preview and ACIS spline-surface faces. The kernel now has both, over one de Boor written across however many coordinates a control point holds — so a plane curve and a space curve cannot drift apart, and a surface is the same algorithm applied twice. The rational and the polynomial cases stop being separate types: weights absent means polynomial. The entity conversion. Every LINE, ARC, ELLIPSE, SPLINE and polyline was built into truck topology purely so it could be sampled back into points. They are sampled through `entities::curve` now, which is where each one's geometry is already defined once and what EXTRUDE and REVOLVE read — so a circle drawn on screen and a circle handed to the Model tab come from the same definition. That retires four of TruckObject's variants and the module is renamed for what it does. SWEEP and LOFT. Both only ever produced a mesh, so both are built from point lists: a band of quads per span, and a lid where a profile closes. Lofting profiles of different densities resamples them by distance rather than by index, so a circle lofted to a square no longer twists. Two things worth noting for anyone reading the old comments. The tolerance globals that lived in the tessellation module were never about truck at all — they are the per-frame chord height, and they move to `curve_tol`. And the rule that a profile handed over as `Lines` silently broke EXTRUDE and REVOLVE no longer holds: those read the curve directly and never look at this channel. The two `automation` test failures are unchanged from before this and are not caused by it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 01:08:50 +03:00
use crate::scene::model::sweep_model;
2026-05-12 10:36:15 +03:00
let profile_ent = self.tabs[i]
.scene
.document
.get_entity(profile_handle)
.cloned();
let path_ent = self.tabs[i].scene.document.get_entity(path_handle).cloned();
let history = profile_ent
.as_ref()
.zip(path_ent.as_ref())
.map(|(profile, path)| {
crate::scene::model::solid_history::sweep_op(profile, path)
});
refactor: drop truck, and evaluate every curve through the kernel The last four truck crates are gone from the manifest. What they were doing falls into three parts, and each now has one answer instead of two. NURBS evaluation. truck was carrying B-spline curves and surfaces for the SPLINE entity, hatch boundaries, BLEND's endpoint frames, the spline preview and ACIS spline-surface faces. The kernel now has both, over one de Boor written across however many coordinates a control point holds — so a plane curve and a space curve cannot drift apart, and a surface is the same algorithm applied twice. The rational and the polynomial cases stop being separate types: weights absent means polynomial. The entity conversion. Every LINE, ARC, ELLIPSE, SPLINE and polyline was built into truck topology purely so it could be sampled back into points. They are sampled through `entities::curve` now, which is where each one's geometry is already defined once and what EXTRUDE and REVOLVE read — so a circle drawn on screen and a circle handed to the Model tab come from the same definition. That retires four of TruckObject's variants and the module is renamed for what it does. SWEEP and LOFT. Both only ever produced a mesh, so both are built from point lists: a band of quads per span, and a lid where a profile closes. Lofting profiles of different densities resamples them by distance rather than by index, so a circle lofted to a square no longer twists. Two things worth noting for anyone reading the old comments. The tolerance globals that lived in the tessellation module were never about truck at all — they are the per-frame chord height, and they move to `curve_tol`. And the rule that a profile handed over as `Lines` silently broke EXTRUDE and REVOLVE no longer holds: those read the curve directly and never look at this channel. The two `automation` test failures are unchanged from before this and are not caused by it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 01:08:50 +03:00
let result = profile_ent
.zip(path_ent)
.and_then(|(profile, path)| sweep_model::swept(&profile, &path, color));
refactor: drop truck, and evaluate every curve through the kernel The last four truck crates are gone from the manifest. What they were doing falls into three parts, and each now has one answer instead of two. NURBS evaluation. truck was carrying B-spline curves and surfaces for the SPLINE entity, hatch boundaries, BLEND's endpoint frames, the spline preview and ACIS spline-surface faces. The kernel now has both, over one de Boor written across however many coordinates a control point holds — so a plane curve and a space curve cannot drift apart, and a surface is the same algorithm applied twice. The rational and the polynomial cases stop being separate types: weights absent means polynomial. The entity conversion. Every LINE, ARC, ELLIPSE, SPLINE and polyline was built into truck topology purely so it could be sampled back into points. They are sampled through `entities::curve` now, which is where each one's geometry is already defined once and what EXTRUDE and REVOLVE read — so a circle drawn on screen and a circle handed to the Model tab come from the same definition. That retires four of TruckObject's variants and the module is renamed for what it does. SWEEP and LOFT. Both only ever produced a mesh, so both are built from point lists: a band of quads per span, and a lid where a profile closes. Lofting profiles of different densities resamples them by distance rather than by index, so a circle lofted to a square no longer twists. Two things worth noting for anyone reading the old comments. The tolerance globals that lived in the tessellation module were never about truck at all — they are the per-frame chord height, and they move to `curve_tol`. And the rule that a profile handed over as `Lines` silently broke EXTRUDE and REVOLVE no longer holds: those read the curve directly and never look at this channel. The two `automation` test failures are unchanged from before this and are not caused by it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 01:08:50 +03:00
if let Some(mut set) = result {
let pending = self.begin_undo(i, "SWEEP", 1, true);
let mut entity = empty_solid3d();
if let acadrust::EntityType::Solid3D(solid) = &mut entity {
let center = set.metrics.centroid;
solid.point_of_reference = acadrust::types::Vector3::new(
center[0], center[1], center[2],
);
}
let new_handle = self.tabs[i].scene.add_entity(entity);
if let Some(history) = history {
self.tabs[i]
.scene
.create_solid_history(new_handle, history);
}
refactor: drop truck, and evaluate every curve through the kernel The last four truck crates are gone from the manifest. What they were doing falls into three parts, and each now has one answer instead of two. NURBS evaluation. truck was carrying B-spline curves and surfaces for the SPLINE entity, hatch boundaries, BLEND's endpoint frames, the spline preview and ACIS spline-surface faces. The kernel now has both, over one de Boor written across however many coordinates a control point holds — so a plane curve and a space curve cannot drift apart, and a surface is the same algorithm applied twice. The rational and the polynomial cases stop being separate types: weights absent means polynomial. The entity conversion. Every LINE, ARC, ELLIPSE, SPLINE and polyline was built into truck topology purely so it could be sampled back into points. They are sampled through `entities::curve` now, which is where each one's geometry is already defined once and what EXTRUDE and REVOLVE read — so a circle drawn on screen and a circle handed to the Model tab come from the same definition. That retires four of TruckObject's variants and the module is renamed for what it does. SWEEP and LOFT. Both only ever produced a mesh, so both are built from point lists: a band of quads per span, and a lid where a profile closes. Lofting profiles of different densities resamples them by distance rather than by index, so a circle lofted to a square no longer twists. Two things worth noting for anyone reading the old comments. The tolerance globals that lived in the tessellation module were never about truck at all — they are the per-frame chord height, and they move to `curve_tol`. And the rule that a profile handed over as `Lines` silently broke EXTRUDE and REVOLVE no longer holds: those read the curve directly and never look at this channel. The two `automation` test failures are unchanged from before this and are not caused by it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 01:08:50 +03:00
for mesh in &mut set.lods {
mesh.name = format!("{}", new_handle.value());
}
self.tabs[i].scene.meshes.insert(new_handle, set);
self.tabs[i].dirty = true;
2026-08-02 22:09:25 +03:00
self.command_line.push_output(crate::t!("SWEEP: solid created.").as_ref());
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
} else {
refactor: drop truck, and evaluate every curve through the kernel The last four truck crates are gone from the manifest. What they were doing falls into three parts, and each now has one answer instead of two. NURBS evaluation. truck was carrying B-spline curves and surfaces for the SPLINE entity, hatch boundaries, BLEND's endpoint frames, the spline preview and ACIS spline-surface faces. The kernel now has both, over one de Boor written across however many coordinates a control point holds — so a plane curve and a space curve cannot drift apart, and a surface is the same algorithm applied twice. The rational and the polynomial cases stop being separate types: weights absent means polynomial. The entity conversion. Every LINE, ARC, ELLIPSE, SPLINE and polyline was built into truck topology purely so it could be sampled back into points. They are sampled through `entities::curve` now, which is where each one's geometry is already defined once and what EXTRUDE and REVOLVE read — so a circle drawn on screen and a circle handed to the Model tab come from the same definition. That retires four of TruckObject's variants and the module is renamed for what it does. SWEEP and LOFT. Both only ever produced a mesh, so both are built from point lists: a band of quads per span, and a lid where a profile closes. Lofting profiles of different densities resamples them by distance rather than by index, so a circle lofted to a square no longer twists. Two things worth noting for anyone reading the old comments. The tolerance globals that lived in the tessellation module were never about truck at all — they are the per-frame chord height, and they move to `curve_tol`. And the rule that a profile handed over as `Lines` silently broke EXTRUDE and REVOLVE no longer holds: those read the curve directly and never look at this channel. The two `automation` test failures are unchanged from before this and are not caused by it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 01:08:50 +03:00
self.command_line.push_error(crate::t!("SWEEP: could not sweep the profile along the path.").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();
}
refactor: drop truck, and evaluate every curve through the kernel The last four truck crates are gone from the manifest. What they were doing falls into three parts, and each now has one answer instead of two. NURBS evaluation. truck was carrying B-spline curves and surfaces for the SPLINE entity, hatch boundaries, BLEND's endpoint frames, the spline preview and ACIS spline-surface faces. The kernel now has both, over one de Boor written across however many coordinates a control point holds — so a plane curve and a space curve cannot drift apart, and a surface is the same algorithm applied twice. The rational and the polynomial cases stop being separate types: weights absent means polynomial. The entity conversion. Every LINE, ARC, ELLIPSE, SPLINE and polyline was built into truck topology purely so it could be sampled back into points. They are sampled through `entities::curve` now, which is where each one's geometry is already defined once and what EXTRUDE and REVOLVE read — so a circle drawn on screen and a circle handed to the Model tab come from the same definition. That retires four of TruckObject's variants and the module is renamed for what it does. SWEEP and LOFT. Both only ever produced a mesh, so both are built from point lists: a band of quads per span, and a lid where a profile closes. Lofting profiles of different densities resamples them by distance rather than by index, so a circle lofted to a square no longer twists. Two things worth noting for anyone reading the old comments. The tolerance globals that lived in the tessellation module were never about truck at all — they are the per-frame chord height, and they move to `curve_tol`. And the rule that a profile handed over as `Lines` silently broke EXTRUDE and REVOLVE no longer holds: those read the curve directly and never look at this channel. The two `automation` test failures are unchanged from before this and are not caused by it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 01:08:50 +03:00
// ── LOFT ──────────────────────────────────────────────────────
CmdResult::LoftEntities { handles, color } => {
if let Some(handle) = handles
.iter()
.find(|handle| self.tabs[i].scene.is_layer_locked(**handle))
.copied()
{
self.reject_locked_edit(i, handle);
self.tabs[i].active_cmd = None;
return Task::none();
}
2026-05-12 10:36:15 +03:00
use crate::modules::insert::solid3d_cmds::empty_solid3d;
refactor: drop truck, and evaluate every curve through the kernel The last four truck crates are gone from the manifest. What they were doing falls into three parts, and each now has one answer instead of two. NURBS evaluation. truck was carrying B-spline curves and surfaces for the SPLINE entity, hatch boundaries, BLEND's endpoint frames, the spline preview and ACIS spline-surface faces. The kernel now has both, over one de Boor written across however many coordinates a control point holds — so a plane curve and a space curve cannot drift apart, and a surface is the same algorithm applied twice. The rational and the polynomial cases stop being separate types: weights absent means polynomial. The entity conversion. Every LINE, ARC, ELLIPSE, SPLINE and polyline was built into truck topology purely so it could be sampled back into points. They are sampled through `entities::curve` now, which is where each one's geometry is already defined once and what EXTRUDE and REVOLVE read — so a circle drawn on screen and a circle handed to the Model tab come from the same definition. That retires four of TruckObject's variants and the module is renamed for what it does. SWEEP and LOFT. Both only ever produced a mesh, so both are built from point lists: a band of quads per span, and a lid where a profile closes. Lofting profiles of different densities resamples them by distance rather than by index, so a circle lofted to a square no longer twists. Two things worth noting for anyone reading the old comments. The tolerance globals that lived in the tessellation module were never about truck at all — they are the per-frame chord height, and they move to `curve_tol`. And the rule that a profile handed over as `Lines` silently broke EXTRUDE and REVOLVE no longer holds: those read the curve directly and never look at this channel. The two `automation` test failures are unchanged from before this and are not caused by it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 01:08:50 +03:00
use crate::scene::model::sweep_model;
refactor: drop truck, and evaluate every curve through the kernel The last four truck crates are gone from the manifest. What they were doing falls into three parts, and each now has one answer instead of two. NURBS evaluation. truck was carrying B-spline curves and surfaces for the SPLINE entity, hatch boundaries, BLEND's endpoint frames, the spline preview and ACIS spline-surface faces. The kernel now has both, over one de Boor written across however many coordinates a control point holds — so a plane curve and a space curve cannot drift apart, and a surface is the same algorithm applied twice. The rational and the polynomial cases stop being separate types: weights absent means polynomial. The entity conversion. Every LINE, ARC, ELLIPSE, SPLINE and polyline was built into truck topology purely so it could be sampled back into points. They are sampled through `entities::curve` now, which is where each one's geometry is already defined once and what EXTRUDE and REVOLVE read — so a circle drawn on screen and a circle handed to the Model tab come from the same definition. That retires four of TruckObject's variants and the module is renamed for what it does. SWEEP and LOFT. Both only ever produced a mesh, so both are built from point lists: a band of quads per span, and a lid where a profile closes. Lofting profiles of different densities resamples them by distance rather than by index, so a circle lofted to a square no longer twists. Two things worth noting for anyone reading the old comments. The tolerance globals that lived in the tessellation module were never about truck at all — they are the per-frame chord height, and they move to `curve_tol`. And the rule that a profile handed over as `Lines` silently broke EXTRUDE and REVOLVE no longer holds: those read the curve directly and never look at this channel. The two `automation` test failures are unchanged from before this and are not caused by it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 01:08:50 +03:00
let profiles: Vec<acadrust::EntityType> = handles
.iter()
.filter_map(|handle| self.tabs[i].scene.document.get_entity(*handle).cloned())
.collect();
if let Some(mut set) = sweep_model::lofted(&profiles, color) {
let history = crate::scene::model::solid_history::loft_op(&profiles);
let pending = self.begin_undo(i, "LOFT", 1, true);
let mut entity = empty_solid3d();
if let acadrust::EntityType::Solid3D(solid) = &mut entity {
let center = set.metrics.centroid;
solid.point_of_reference = acadrust::types::Vector3::new(
center[0], center[1], center[2],
);
}
let new_handle = self.tabs[i].scene.add_entity(entity);
self.tabs[i]
.scene
.create_solid_history(new_handle, history);
refactor: drop truck, and evaluate every curve through the kernel The last four truck crates are gone from the manifest. What they were doing falls into three parts, and each now has one answer instead of two. NURBS evaluation. truck was carrying B-spline curves and surfaces for the SPLINE entity, hatch boundaries, BLEND's endpoint frames, the spline preview and ACIS spline-surface faces. The kernel now has both, over one de Boor written across however many coordinates a control point holds — so a plane curve and a space curve cannot drift apart, and a surface is the same algorithm applied twice. The rational and the polynomial cases stop being separate types: weights absent means polynomial. The entity conversion. Every LINE, ARC, ELLIPSE, SPLINE and polyline was built into truck topology purely so it could be sampled back into points. They are sampled through `entities::curve` now, which is where each one's geometry is already defined once and what EXTRUDE and REVOLVE read — so a circle drawn on screen and a circle handed to the Model tab come from the same definition. That retires four of TruckObject's variants and the module is renamed for what it does. SWEEP and LOFT. Both only ever produced a mesh, so both are built from point lists: a band of quads per span, and a lid where a profile closes. Lofting profiles of different densities resamples them by distance rather than by index, so a circle lofted to a square no longer twists. Two things worth noting for anyone reading the old comments. The tolerance globals that lived in the tessellation module were never about truck at all — they are the per-frame chord height, and they move to `curve_tol`. And the rule that a profile handed over as `Lines` silently broke EXTRUDE and REVOLVE no longer holds: those read the curve directly and never look at this channel. The two `automation` test failures are unchanged from before this and are not caused by it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 01:08:50 +03:00
for mesh in &mut set.lods {
mesh.name = format!("{}", new_handle.value());
}
self.tabs[i].scene.meshes.insert(new_handle, set);
self.tabs[i].dirty = true;
refactor: drop truck, and evaluate every curve through the kernel The last four truck crates are gone from the manifest. What they were doing falls into three parts, and each now has one answer instead of two. NURBS evaluation. truck was carrying B-spline curves and surfaces for the SPLINE entity, hatch boundaries, BLEND's endpoint frames, the spline preview and ACIS spline-surface faces. The kernel now has both, over one de Boor written across however many coordinates a control point holds — so a plane curve and a space curve cannot drift apart, and a surface is the same algorithm applied twice. The rational and the polynomial cases stop being separate types: weights absent means polynomial. The entity conversion. Every LINE, ARC, ELLIPSE, SPLINE and polyline was built into truck topology purely so it could be sampled back into points. They are sampled through `entities::curve` now, which is where each one's geometry is already defined once and what EXTRUDE and REVOLVE read — so a circle drawn on screen and a circle handed to the Model tab come from the same definition. That retires four of TruckObject's variants and the module is renamed for what it does. SWEEP and LOFT. Both only ever produced a mesh, so both are built from point lists: a band of quads per span, and a lid where a profile closes. Lofting profiles of different densities resamples them by distance rather than by index, so a circle lofted to a square no longer twists. Two things worth noting for anyone reading the old comments. The tolerance globals that lived in the tessellation module were never about truck at all — they are the per-frame chord height, and they move to `curve_tol`. And the rule that a profile handed over as `Lines` silently broke EXTRUDE and REVOLVE no longer holds: those read the curve directly and never look at this channel. The two `automation` test failures are unchanged from before this and are not caused by it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 01:08:50 +03:00
self.command_line.push_output(crate::t!("LOFT: solid created.").as_ref());
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
} else {
refactor: drop truck, and evaluate every curve through the kernel The last four truck crates are gone from the manifest. What they were doing falls into three parts, and each now has one answer instead of two. NURBS evaluation. truck was carrying B-spline curves and surfaces for the SPLINE entity, hatch boundaries, BLEND's endpoint frames, the spline preview and ACIS spline-surface faces. The kernel now has both, over one de Boor written across however many coordinates a control point holds — so a plane curve and a space curve cannot drift apart, and a surface is the same algorithm applied twice. The rational and the polynomial cases stop being separate types: weights absent means polynomial. The entity conversion. Every LINE, ARC, ELLIPSE, SPLINE and polyline was built into truck topology purely so it could be sampled back into points. They are sampled through `entities::curve` now, which is where each one's geometry is already defined once and what EXTRUDE and REVOLVE read — so a circle drawn on screen and a circle handed to the Model tab come from the same definition. That retires four of TruckObject's variants and the module is renamed for what it does. SWEEP and LOFT. Both only ever produced a mesh, so both are built from point lists: a band of quads per span, and a lid where a profile closes. Lofting profiles of different densities resamples them by distance rather than by index, so a circle lofted to a square no longer twists. Two things worth noting for anyone reading the old comments. The tolerance globals that lived in the tessellation module were never about truck at all — they are the per-frame chord height, and they move to `curve_tol`. And the rule that a profile handed over as `Lines` silently broke EXTRUDE and REVOLVE no longer holds: those read the curve directly and never look at this channel. The two `automation` test failures are unchanged from before this and are not caused by it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 01:08:50 +03:00
self.command_line.push_error(crate::t!("LOFT: select at least two closed profiles.").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();
}
2026-05-12 10:36:15 +03:00
CmdResult::HatcheditApply {
handle,
name,
scale,
angle,
} => {
if self.reject_locked_edit(i, handle) {
return Task::none();
}
if let Some(mut model) = self.tabs[i].scene.hatches.get(&handle).cloned() {
let layer = self.tabs[i]
.scene
.document
.get_entity(handle)
.map(|entity| entity.as_entity().layer().to_string())
.unwrap_or_else(|| "0".to_string());
// Update model fields
if !name.is_empty() {
use crate::scene::model::hatch_model::HatchPattern;
use crate::scene::model::hatch_patterns;
model.name = name.clone();
if name.to_uppercase() == "SOLID" {
model.pattern = HatchPattern::Solid;
} else if let Some(entry) = hatch_patterns::find(&name) {
model.pattern = entry.gpu.clone();
}
// If not found in catalog, keep existing pattern type
}
model.scale = scale;
model.angle_offset = angle;
self.push_undo_snapshot(i, "HATCHEDIT");
// Remove old hatch (entity + GPU model)
self.tabs[i].scene.erase_entities(&[handle]);
// Re-add with updated model
self.tabs[i].scene.add_hatch(model, Some(&layer), None);
self.tabs[i].dirty = true;
2026-08-02 22:09:25 +03:00
self.command_line.push_output(crate::t!("HATCHEDIT: hatch updated.").as_ref());
} else {
2026-05-12 10:36:15 +03:00
self.command_line
2026-08-02 22:09:25 +03:00
.push_error(crate::t!("HATCHEDIT: hatch entity not found.").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,
} => {
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.open_mtext_editor(pos, handle, &initial, height);
}
CmdResult::OpenTextEditor {
pos,
handle,
initial,
height,
} => {
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.open_text_inline(
pos,
handle,
&initial,
height,
super::text_inline::TextEntityField::Text,
);
}
CmdResult::EditTextEntity { handle } => {
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.tabs[i].scene.clear_preview_wire();
self.restore_pre_cmd_tangent();
self.ribbon.deactivate_tool();
return self.begin_text_edit(handle);
}
CmdResult::SuspendForTextEdit { handle } => {
let is_editable =
crate::app::text_inline::can_edit_text(handle, &self.tabs[i].scene.document);
if !is_editable {
self.command_line
2026-08-02 22:09:25 +03:00
.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;
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
2026-08-02 22:09:25 +03:00
.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::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 {
2026-05-12 10:36:15 +03:00
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;
2026-08-02 22:09:25 +03:00
self.command_line.push_output(crate::t!("DDEDIT: text updated.").as_ref());
} else {
self.discard_last_undo_entry(i);
2026-05-12 10:36:15 +03:00
self.command_line
2026-08-02 22:09:25 +03:00
.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();
}
}
// 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);
}
// 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 ───────────────────────────────────────────────────────────
/// Parse `base_val,h1;h2;...;hN,spacing` and adjust parallel dimension positions.
fn apply_dimspace(scene: &mut crate::scene::Scene, encoded: &str) {
// Format: "<base_handle>,<h1>;<h2>;...;<hN>,<spacing>"
let parts: Vec<&str> = encoded.splitn(3, ',').collect();
2026-05-12 10:36:15 +03:00
if parts.len() < 3 {
return;
}
let base_val: u64 = parts[0].parse().unwrap_or(0);
2026-05-12 10:36:15 +03:00
let other_vals: Vec<u64> = parts[1]
.split(';')
.filter_map(|s| s.parse::<u64>().ok())
.collect();
let spacing: f64 = parts[2].parse().unwrap_or(0.0);
use acadrust::entities::Dimension;
let base_h = acadrust::Handle::from(base_val);
// Base dim: the perpendicular direction (from its rotation / axis) and the
// dim line's perpendicular coordinate. Spacing steps each parallel dim along
// this perp IN THE DRAWING PLANE — offsetting Z had no effect on the dim
// line, which is computed from def·perp with perp.z = 0. (#181 / DIM-021)
let (perp, base_coord) = match scene.document.get_entity(base_h) {
Some(acadrust::EntityType::Dimension(Dimension::Linear(d))) => {
let (s, c) = d.rotation.sin_cos();
let perp = (-s, c);
let dp = d.definition_point;
(perp, dp.x * perp.0 + dp.y * perp.1)
}
Some(acadrust::EntityType::Dimension(Dimension::Aligned(d))) => {
let dx = d.second_point.x - d.first_point.x;
let dy = d.second_point.y - d.first_point.y;
let len = (dx * dx + dy * dy).sqrt().max(1e-12);
let perp = (-dy / len, dx / len);
let dp = d.definition_point;
(perp, dp.x * perp.0 + dp.y * perp.1)
}
_ => return,
};
let effective_spacing = if spacing <= 0.0 { 10.0 } else { spacing };
let mut changes = Vec::new();
for (idx, &hv) in other_vals.iter().enumerate() {
let h = acadrust::Handle::from(hv);
let target = base_coord + effective_spacing * (idx + 1) as f64;
let mut changed = false;
if let Some(acadrust::EntityType::Dimension(dim)) = scene.document.get_entity_mut(h) {
// Slide this dim's definition point along perp so its perpendicular
// coordinate equals `target`; update both the struct field (render)
// and base (save).
let slide = |p: &mut acadrust::types::Vector3| {
let cur = p.x * perp.0 + p.y * perp.1;
let delta = target - cur;
p.x += perp.0 * delta;
p.y += perp.1 * delta;
};
match dim {
Dimension::Linear(d) => {
slide(&mut d.definition_point);
d.base.definition_point = d.definition_point;
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);
}
}
// ── MLEADERALIGN helper ───────────────────────────────────────────────────────
/// Parse `h1,h2,...;fx,fz;tx,tz` and align multileader content points along the direction.
fn apply_mleader_align(scene: &mut crate::scene::Scene, encoded: &str) {
// Format: "<h1>,<h2>,...;<fx>,<fz>;<tx>,<tz>"
let parts: Vec<&str> = encoded.splitn(3, ';').collect();
2026-05-12 10:36:15 +03:00
if parts.len() < 3 {
return;
}
let handles: Vec<acadrust::Handle> = parts[0]
.split(',')
.filter_map(|s| s.parse::<u64>().ok().map(acadrust::Handle::from))
.collect();
2026-05-12 10:36:15 +03:00
let from_parts: Vec<f64> = parts[1].split(',').filter_map(|s| s.parse().ok()).collect();
let to_parts: Vec<f64> = parts[2].split(',').filter_map(|s| s.parse().ok()).collect();
if from_parts.len() < 2 || to_parts.len() < 2 || handles.is_empty() {
return;
}
let fx = from_parts[0];
let fz = from_parts[1];
let tx = to_parts[0];
let tz = to_parts[1];
let dx = tx - fx;
let dz = tz - fz;
let len = (dx * dx + dz * dz).sqrt();
2026-05-12 10:36:15 +03:00
if len < 1e-9 {
return;
}
// Project each multileader's content point onto the alignment line, then
// snap it to the line (preserve perpendicular offset from line is discarded;
// align along direction through `from`).
for h in handles {
if let Some(acadrust::EntityType::MultiLeader(ml)) = scene.document.get_entity_mut(h) {
let cp = &mut ml.context.content_base_point;
// Project onto line from_pt + t * dir: keep t component, set perpendicular = 0
let rel_x = cp.x - fx;
let rel_z = cp.z - fz;
let t = (rel_x * (dx / len) + rel_z * (dz / len)) / len;
let t = t.clamp(0.0, 1.0);
cp.x = fx + t * dx;
cp.z = fz + t * dz;
}
}
}
// ── MLEADERCOLLECT helper ─────────────────────────────────────────────────────
/// Parse `h1,h2,...;px,pz` — merge all selected multileaders into the first one at position.
fn apply_mleader_collect(scene: &mut crate::scene::Scene, encoded: &str) {
let parts: Vec<&str> = encoded.splitn(2, ';').collect();
2026-05-12 10:36:15 +03:00
if parts.len() < 2 {
return;
}
let handles: Vec<acadrust::Handle> = parts[0]
.split(',')
.filter_map(|s| s.parse::<u64>().ok().map(acadrust::Handle::from))
.collect();
2026-05-12 10:36:15 +03:00
let pos_parts: Vec<f64> = parts[1].split(',').filter_map(|s| s.parse().ok()).collect();
if handles.len() < 2 || pos_parts.len() < 2 {
return;
}
let px = pos_parts[0];
let pz = pos_parts[1];
// Collect all leader roots from the secondary multileaders.
let mut extra_roots: Vec<acadrust::entities::LeaderRoot> = Vec::new();
for &h in &handles[1..] {
if let Some(acadrust::EntityType::MultiLeader(ml)) = scene.document.get_entity(h) {
extra_roots.extend(ml.context.leader_roots.iter().cloned());
}
}
// Add collected roots to the first multileader and move its content point.
if let Some(acadrust::EntityType::MultiLeader(ml)) = scene.document.get_entity_mut(handles[0]) {
ml.context.content_base_point.x = px;
ml.context.content_base_point.z = pz;
for root in extra_roots {
ml.context.leader_roots.push(root);
}
}
// Erase the secondary multileaders.
scene.erase_entities(&handles[1..]);
}
/// MATCHPROP special properties (#281): copy every STYLE-affecting field from
/// `src` to `dst` when the destination supports it — never content (text
/// strings, block names) or placement (positions, rotations of the object
/// itself). Text formatting crosses TEXT ↔ MTEXT; the dimension style crosses
/// Dimension / Leader / Tolerance.
fn match_special_props(src: &acadrust::EntityType, dst: &mut acadrust::EntityType) {
use acadrust::EntityType as E;
// Text-ish source formatting.
let text_fmt = match src {
E::Text(t) => Some((
t.style.clone(),
t.height,
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()
}