2026-05-23 17:16:15 +03:00
|
|
|
use super::{Message, OpenCADStudio};
|
2026-08-04 00:01:12 +03:00
|
|
|
use crate::command::{CmdResult, SelectionEntity, StepInput};
|
2026-03-22 22:44:32 +03:00
|
|
|
use acadrust::Handle;
|
|
|
|
|
use iced::Task;
|
|
|
|
|
|
2026-05-23 17:16:15 +03:00
|
|
|
impl OpenCADStudio {
|
2026-08-12 12:46:41 +03:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-13 17:16:29 +03:00
|
|
|
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;
|
|
|
|
|
}
|
2026-08-04 00:01:12 +03:00
|
|
|
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(®ions);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-01 19:09:19 +03:00
|
|
|
/// 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())
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-28 18:51:20 +03:00
|
|
|
/// 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;
|
2026-07-28 18:51:20 +03:00
|
|
|
self.axis_lock_dir = None;
|
|
|
|
|
self.dyn_user_reshaped = false;
|
2026-08-02 23:32:54 +03:00
|
|
|
self.dyn_coord_absolute = false;
|
2026-07-28 18:51:20 +03:00
|
|
|
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;
|
2026-08-04 18:21:52 +03:00
|
|
|
self.tabs[i].orbit_mode = false;
|
2026-08-08 20:37:53 +03:00
|
|
|
self.tabs[i].zoom_dynamic_mode = false;
|
2026-07-28 18:51:20 +03:00
|
|
|
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.
|
2026-08-11 20:10:23 +03:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-28 18:51:20 +03:00
|
|
|
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()
|
2026-08-11 20:10:23 +03:00
|
|
|
|| !self.grip_preview_handles.is_empty()
|
|
|
|
|
|| !self.grip_history_originals.is_empty();
|
2026-07-28 18:51:20 +03:00
|
|
|
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)]);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-31 22:05:30 +03:00
|
|
|
let handles = std::mem::take(&mut self.grip_preview_handles);
|
|
|
|
|
let originals = std::mem::take(&mut self.grip_originals);
|
2026-08-11 20:10:23 +03:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-31 23:36:07 +03:00
|
|
|
let mut changed_handles: rustc_hash::FxHashSet<_> = handles.iter().copied().collect();
|
2026-07-31 22:05:30 +03:00
|
|
|
for (handle, original) in originals {
|
2026-07-31 23:36:07 +03:00
|
|
|
changed_handles.insert(handle);
|
2026-08-11 20:10:23 +03:00
|
|
|
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],
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-08-11 19:39:01 +03:00
|
|
|
}
|
2026-07-31 22:05:30 +03:00
|
|
|
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) {
|
|
|
|
|
*entity = original;
|
2026-07-28 18:51:20 +03:00
|
|
|
}
|
2026-07-31 22:05:30 +03:00
|
|
|
}
|
2026-08-11 20:10:23 +03:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-31 22:05:30 +03:00
|
|
|
for &handle in &handles {
|
2026-07-28 18:51:20 +03:00
|
|
|
self.tabs[i].scene.preview_hidden.remove(&handle);
|
2026-07-31 22:05:30 +03:00
|
|
|
}
|
2026-07-31 23:36:07 +03:00
|
|
|
let changes: Vec<_> = changed_handles
|
2026-07-31 22:05:30 +03:00
|
|
|
.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;
|
2026-07-28 18:51:20 +03:00
|
|
|
}
|
|
|
|
|
|
2026-08-10 13:26:35 -03:00
|
|
|
self.grip_snap_wires.clear();
|
2026-07-28 18:51:20 +03:00
|
|
|
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());
|
2026-07-28 18:51:20 +03:00
|
|
|
}
|
|
|
|
|
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());
|
2026-07-28 18:51:20 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 16:15:28 +03:00
|
|
|
/// 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());
|
2026-07-27 16:15:28 +03:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
true
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-25 12:24:17 +03:00
|
|
|
/// 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> {
|
2026-07-21 11:01:51 +03:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-25 12:24:17 +03:00
|
|
|
let i = self.active_tab;
|
2026-08-01 19:09:19 +03:00
|
|
|
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
|
|
|
|
|
};
|
2026-07-27 16:15:28 +03:00
|
|
|
if let StepInput::Point(point) = &input {
|
|
|
|
|
if !self.command_point_allowed(i, *point) {
|
|
|
|
|
return Task::none();
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-01 19:09:19 +03:00
|
|
|
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;
|
2026-08-02 23:32:54 +03:00
|
|
|
self.dyn_coord_absolute = false;
|
2026-08-01 19:09:19 +03:00
|
|
|
self.sync_dyn_fields();
|
|
|
|
|
self.reset_tracking_after_point();
|
|
|
|
|
self.push_ucs_to_cmd(i);
|
|
|
|
|
}
|
2026-08-04 00:01:12 +03:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-08 20:39:40 +03:00
|
|
|
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;
|
2026-06-25 12:24:17 +03:00
|
|
|
let result: Option<CmdResult> = {
|
|
|
|
|
let Some(cmd) = self.tabs[i].active_cmd.as_mut() else {
|
|
|
|
|
return Task::none();
|
|
|
|
|
};
|
2026-07-08 20:39:40 +03:00
|
|
|
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);
|
2026-06-25 12:24:17 +03:00
|
|
|
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(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-25 13:16:28 +03:00
|
|
|
/// 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.
|
2026-07-21 11:01:51 +03:00
|
|
|
/// 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());
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-21 11:01:51 +03:00
|
|
|
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(),
|
2026-07-21 11:01:51 +03:00
|
|
|
"P" | "PREVIOUS" => self.tabs[i]
|
|
|
|
|
.prev_selection
|
|
|
|
|
.iter()
|
|
|
|
|
.copied()
|
2026-08-12 12:46:41 +03:00
|
|
|
.filter(|&h| self.tabs[i].scene.document.get_entity(h).is_some())
|
2026-07-21 11:01:51 +03:00
|
|
|
.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.",
|
2026-07-21 11:01:51 +03:00
|
|
|
_ => "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());
|
2026-07-21 11:01:51 +03:00
|
|
|
self.refresh_properties();
|
|
|
|
|
let handles: Vec<Handle> = self.tabs[i]
|
|
|
|
|
.scene
|
|
|
|
|
.selected_entities()
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|(h, _)| h)
|
|
|
|
|
.collect();
|
2026-08-04 00:01:12 +03:00
|
|
|
Some(self.feed_command(StepInput::SelectionComplete(handles)))
|
2026-07-21 11:01:51 +03:00
|
|
|
}
|
|
|
|
|
|
2026-06-25 13:16:28 +03:00
|
|
|
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;
|
|
|
|
|
}
|
2026-06-25 13:16:28 +03:00
|
|
|
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();
|
2026-07-24 20:49:05 +03:00
|
|
|
let wcs = match (
|
|
|
|
|
matches!(kind, super::helpers::CoordKind::Relative),
|
|
|
|
|
self.last_point,
|
|
|
|
|
) {
|
2026-06-25 13:16:28 +03:00
|
|
|
(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,
|
|
|
|
|
},
|
|
|
|
|
};
|
2026-07-27 16:15:28 +03:00
|
|
|
if !self.command_point_allowed(i, wcs) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-06-25 13:16:28 +03:00
|
|
|
self.last_point = Some(wcs);
|
|
|
|
|
self.push_ucs_to_cmd(i);
|
fix(precision): keep typed coordinates exact in f64 (#311)
Typed values were quantized to the f32 grid before reaching the
entity (e.g. a length of 2000.8 committed as 2000.8020), worsening
with magnitude. The interaction point layer was all glam::Vec3 (f32).
Two-layer fix:
1. Coordinate backbone -> f64. last_point, last_cursor_world,
dyn_anchor, dyn_ref become DVec3; UcsXform transforms, parse_coord,
the ucs helper fns, dyn_resolve_point, the OTRACK typed-distance
paths, command-line coordinate entry and the coordinate readout all
compute in f64. Screen/GPU/alignment stays f32 (snap engine, render
geometry, projection, UCS-icon/viewcube drawing) with casts only at
those boundaries.
2. Per-command committed storage -> DVec3. on_point already received a
DVec3, but many command impls narrowed it back to f32 (let pt =
pt.as_vec3(); Vec<Vec3> fields) and re-widened at build, quantizing
the committed coordinate. Migrated PLINE, SPLINE, MLINE, LENGTHEN,
INSERT, 3D primitives, every dimension/leader/mleader, ray, revcloud,
wipeout, attdef, table, tolerance, array center, mview, plot_window,
plus the QDIM_PLACE handler and qdim_collect_points. Preview,
hit-test and rubber-band paths keep f32 at the boundary.
Hatch manual boundaries are left for a follow-up: HatchModel.boundary
is structurally Arc<Vec<[f32;2]>> and needs a model-level widen.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 21:26:40 +03:00
|
|
|
let _ = self.feed_command(StepInput::Point(wcs));
|
2026-06-25 13:16:28 +03:00
|
|
|
} else {
|
|
|
|
|
let _ = self.feed_command(StepInput::Text(token.to_string()));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-28 10:28:23 +03:00
|
|
|
/// 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.
|
2026-03-22 22:44:32 +03:00
|
|
|
pub(super) fn apply_cmd_result(&mut self, result: CmdResult) -> Task<Message> {
|
2026-06-28 10:28:23 +03:00
|
|
|
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;
|
2026-08-02 16:32:47 +03:00
|
|
|
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);
|
2026-06-28 10:28:23 +03:00
|
|
|
if was_active
|
|
|
|
|
&& !preserve_selection
|
|
|
|
|
&& self.tabs[i].active_cmd.is_none()
|
|
|
|
|
&& !self.tabs[i].scene.selected.is_empty()
|
|
|
|
|
{
|
2026-07-21 11:01:51 +03:00
|
|
|
// Remember the working set for the "Previous" selection keyword
|
|
|
|
|
// (#426) before dropping it.
|
2026-07-24 20:49:05 +03:00
|
|
|
self.tabs[i].prev_selection = self.tabs[i].scene.selected.iter().copied().collect();
|
2026-06-28 10:28:23 +03:00
|
|
|
self.tabs[i].scene.deselect_all();
|
|
|
|
|
self.refresh_properties();
|
|
|
|
|
}
|
2026-07-03 16:05:54 +03:00
|
|
|
// 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() {
|
2026-07-28 19:53:39 +03:00
|
|
|
self.tabs[i].scene.set_hover_highlight(None);
|
2026-07-03 16:05:54 +03:00
|
|
|
self.restore_add_selected_defaults();
|
|
|
|
|
}
|
2026-06-28 10:28:23 +03:00
|
|
|
task
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn apply_cmd_result_inner(&mut self, result: CmdResult) -> Task<Message> {
|
2026-03-22 22:44:32 +03:00
|
|
|
let i = self.active_tab;
|
|
|
|
|
match result {
|
|
|
|
|
CmdResult::NeedPoint => {
|
2026-07-01 08:05:25 +03:00
|
|
|
// 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).
|
2026-04-08 00:19:43 +03:00
|
|
|
let attedit_handle = self.tabs[i]
|
|
|
|
|
.active_cmd
|
|
|
|
|
.as_ref()
|
|
|
|
|
.and_then(|c| c.attedit_pending_handle());
|
|
|
|
|
if let Some(ins_handle) = attedit_handle {
|
2026-07-01 08:05:25 +03:00
|
|
|
self.tabs[i].active_cmd = None;
|
|
|
|
|
self.open_attribute_editor(ins_handle);
|
|
|
|
|
return Task::none();
|
2026-04-08 00:19:43 +03:00
|
|
|
}
|
2026-03-22 22:44:32 +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);
|
|
|
|
|
}
|
2026-07-28 19:53:39 +03:00
|
|
|
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);
|
|
|
|
|
}
|
2026-06-13 23:58:56 +03:00
|
|
|
// 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();
|
2026-08-04 00:01:12 +03:00
|
|
|
self.refresh_area_preview(i);
|
2026-03-22 22:44:32 +03:00
|
|
|
}
|
|
|
|
|
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) => {
|
2026-08-12 12:46:41 +03:00
|
|
|
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();
|
|
|
|
|
}
|
2026-07-09 20:59:46 +03:00
|
|
|
// 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
|
2026-07-09 23:03:25 +03:00
|
|
|
// CommitAndExit arm). Non-continuable repeats (point/ray)
|
2026-07-09 20:59:46 +03:00
|
|
|
// leave the anchor untouched. (#327)
|
|
|
|
|
if matches!(
|
|
|
|
|
entity,
|
|
|
|
|
acadrust::EntityType::Line(_) | acadrust::EntityType::Arc(_)
|
|
|
|
|
) {
|
|
|
|
|
self.update_cont_anchor(&entity);
|
|
|
|
|
}
|
2026-03-22 22:44:32 +03:00
|
|
|
let label = self.history_label_from_active_cmd(i, "ENTITY");
|
2026-07-28 17:16:27 +03:00
|
|
|
// 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);
|
2026-03-22 22:44:32 +03:00
|
|
|
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);
|
|
|
|
|
}
|
2026-03-22 22:44:32 +03:00
|
|
|
}
|
2026-07-27 16:21:31 +03:00
|
|
|
CmdResult::CommitEntities(entities) => {
|
2026-08-12 12:46:41 +03:00
|
|
|
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();
|
|
|
|
|
}
|
2026-07-27 16:21:31 +03:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-13 23:47:50 +03:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-27 19:28:36 +03:00
|
|
|
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");
|
2026-07-28 17:16:27 +03:00
|
|
|
let pending = self.begin_undo(i, label, 1, true);
|
2026-07-27 19:28:36 +03:00
|
|
|
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");
|
2026-07-28 17:16:27 +03:00
|
|
|
let pending = self.begin_undo(i, label, touched, true);
|
2026-07-27 19:28:36 +03:00
|
|
|
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());
|
2026-07-27 19:28:36 +03:00
|
|
|
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());
|
2026-07-27 19:28:36 +03:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-27 21:50:50 +03:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-27 19:28:36 +03:00
|
|
|
CmdResult::MviewSwitchLayout(layout) => {
|
2026-07-28 18:51:20 +03:00
|
|
|
let task = self.on_layout_switch_preserving_command(layout);
|
2026-07-27 19:28:36 +03:00
|
|
|
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);
|
|
|
|
|
}
|
2026-08-12 12:46:41 +03:00
|
|
|
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();
|
|
|
|
|
}
|
2026-03-22 22:44:32 +03:00
|
|
|
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);
|
2026-03-22 22:44:32 +03:00
|
|
|
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);
|
|
|
|
|
}
|
2026-03-22 22:44:32 +03:00
|
|
|
}
|
2026-08-12 12:46:41 +03:00
|
|
|
CmdResult::CopySelected(mut handles, transform) => {
|
|
|
|
|
handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle));
|
|
|
|
|
if handles.is_empty() {
|
|
|
|
|
return Task::none();
|
|
|
|
|
}
|
2026-03-22 22:44:32 +03:00
|
|
|
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);
|
2026-03-22 22:44:32 +03:00
|
|
|
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);
|
|
|
|
|
}
|
2026-03-22 22:44:32 +03:00
|
|
|
}
|
|
|
|
|
CmdResult::CommitAndExit(entity) => {
|
2026-04-08 00:02:38 +03:00
|
|
|
// 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,
|
|
|
|
|
);
|
2026-07-21 15:59:26 +03:00
|
|
|
// 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();
|
2026-04-08 00:02:38 +03:00
|
|
|
}
|
2026-07-08 20:39:40 +03:00
|
|
|
// Record where this draw ended so ARC_CONT can continue from it
|
|
|
|
|
// (before `entity` is moved into commit_entity).
|
|
|
|
|
self.update_cont_anchor(&entity);
|
2026-03-22 22:44:32 +03:00
|
|
|
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);
|
2026-03-22 22:44:32 +03:00
|
|
|
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);
|
|
|
|
|
}
|
2026-03-22 22:44:32 +03:00
|
|
|
}
|
2026-08-11 19:52:17 +03:00
|
|
|
CmdResult::CommitSolid {
|
|
|
|
|
entity,
|
|
|
|
|
solid,
|
|
|
|
|
history,
|
|
|
|
|
} => {
|
2026-05-31 19:27:06 +03:00
|
|
|
let label = self.history_label_from_active_cmd(i, "SOLID");
|
2026-07-25 01:06:00 +03:00
|
|
|
let pending = self.begin_undo(i, label, 1, true);
|
2026-08-11 19:52:17 +03:00
|
|
|
self.add_solid_model(entity, *solid, history);
|
2026-05-31 19:27:06 +03:00
|
|
|
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-07-25 01:06:00 +03:00
|
|
|
if let Some(pd) = pending {
|
|
|
|
|
self.commit_undo_delta(i, pd);
|
|
|
|
|
}
|
2026-05-31 19:27:06 +03:00
|
|
|
}
|
2026-05-31 14:44:18 +03:00
|
|
|
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);
|
2026-05-31 14:44:18 +03:00
|
|
|
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);
|
|
|
|
|
}
|
2026-05-31 14:44:18 +03:00
|
|
|
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");
|
2026-07-25 01:06:00 +03:00
|
|
|
let delta_safe = entities
|
|
|
|
|
.iter()
|
|
|
|
|
.all(|entity| self.delta_add_safe(i, entity));
|
|
|
|
|
let pending = self.begin_undo(i, label, entities.len(), delta_safe);
|
2026-05-31 14:44:18 +03:00
|
|
|
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) {
|
2026-08-13 11:58:54 -03:00
|
|
|
let linked = if let Some(acadrust::EntityType::Leader(l)) =
|
2026-05-31 14:44:18 +03:00
|
|
|
self.tabs[i].scene.document.get_entity_mut(lh)
|
|
|
|
|
{
|
|
|
|
|
l.annotation_handle = ah;
|
2026-08-13 11:58:54 -03:00
|
|
|
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,
|
|
|
|
|
)]);
|
2026-05-31 14:44:18 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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();
|
2026-07-25 01:06:00 +03:00
|
|
|
if let Some(pd) = pending {
|
|
|
|
|
self.commit_undo_delta(i, pd);
|
|
|
|
|
}
|
2026-05-31 14:44:18 +03:00
|
|
|
if let Some(h) = edit_handle {
|
|
|
|
|
return self.begin_text_edit(h);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-12 10:36:15 +03:00
|
|
|
CmdResult::CreateBlock {
|
2026-08-12 12:46:41 +03:00
|
|
|
mut handles,
|
2026-05-12 10:36:15 +03:00
|
|
|
name,
|
|
|
|
|
base,
|
|
|
|
|
} => {
|
2026-08-12 12:46:41 +03:00
|
|
|
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();
|
|
|
|
|
}
|
2026-03-22 22:44:32 +03:00
|
|
|
self.push_undo_snapshot(i, "BLOCK");
|
2026-07-30 19:50:33 +03:00
|
|
|
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
|
2026-07-30 19:50:33 +03:00
|
|
|
.create_block_from_entities(
|
|
|
|
|
&handles,
|
|
|
|
|
&name,
|
|
|
|
|
&world_to_block,
|
|
|
|
|
&block_to_world,
|
|
|
|
|
)
|
2026-05-12 10:36:15 +03:00
|
|
|
{
|
2026-03-22 22:44:32 +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());
|
2026-03-22 22:44:32 +03:00
|
|
|
self.refresh_properties();
|
|
|
|
|
}
|
|
|
|
|
Err(err) => {
|
2026-07-24 23:25:33 +03:00
|
|
|
self.discard_last_undo_entry(i);
|
2026-03-22 22:44:32 +03:00
|
|
|
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");
|
2026-07-25 01:06:00 +03:00
|
|
|
let pending = self.begin_undo(i, label, 1, true);
|
2026-08-12 00:23:21 -03:00
|
|
|
let layer = self.tabs[i].active_layer.clone();
|
2026-08-13 23:47:50 +03:00
|
|
|
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);
|
2026-03-22 22:44:32 +03:00
|
|
|
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();
|
2026-07-25 01:06:00 +03:00
|
|
|
if let Some(pd) = pending {
|
|
|
|
|
self.commit_undo_delta(i, pd);
|
|
|
|
|
}
|
2026-03-22 22:44:32 +03:00
|
|
|
}
|
2026-08-12 12:46:41 +03:00
|
|
|
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();
|
|
|
|
|
}
|
2026-03-22 22:44:32 +03:00
|
|
|
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);
|
2026-07-24 20:49:05 +03:00
|
|
|
let pending = self.begin_undo(i, label.clone(), handles.len() * count, delta_safe);
|
2026-03-22 22:44:32 +03:00
|
|
|
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();
|
2026-06-15 23:42:54 +03:00
|
|
|
let noun = if count == 1 { "copy" } else { "copies" };
|
2026-03-22 22:44:32 +03:00
|
|
|
self.command_line
|
2026-08-02 22:09:25 +03:00
|
|
|
.push_output(crate::tf!("{label}: {count} {noun} created.").as_ref());
|
2026-03-22 22:44:32 +03:00
|
|
|
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-03-22 22:44:32 +03:00
|
|
|
}
|
|
|
|
|
CmdResult::ReplaceMany(replacements, additions) => {
|
2026-08-12 12:46:41 +03:00
|
|
|
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();
|
|
|
|
|
}
|
2026-03-22 22:44:32 +03:00
|
|
|
let label = self.history_label_from_active_cmd(i, "FILLET");
|
2026-07-24 20:49:05 +03:00
|
|
|
let was_catchment = self.tabs[i]
|
2026-06-09 13:41:21 -04:00
|
|
|
.active_cmd
|
|
|
|
|
.as_ref()
|
|
|
|
|
.is_some_and(|c| c.name() == "SS_CATCHMENT");
|
2026-03-22 22:44:32 +03:00
|
|
|
self.push_undo_snapshot(i, label);
|
|
|
|
|
for (handle, entities) in replacements {
|
|
|
|
|
self.tabs[i].scene.erase_entities(&[handle]);
|
|
|
|
|
for entity in entities {
|
2026-07-08 10:23:48 +03:00
|
|
|
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(_))
|
|
|
|
|
) {
|
2026-07-24 23:25:33 +03:00
|
|
|
self.tabs[i].scene.invalidate_dim_block_recorded(nh);
|
2026-07-08 10:23:48 +03:00
|
|
|
}
|
2026-03-22 22:44:32 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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;
|
2026-06-09 13:41:21 -04:00
|
|
|
if was_catchment {
|
|
|
|
|
self.command_line
|
2026-08-02 22:09:25 +03:00
|
|
|
.push_info(crate::t!("Catchment tagged successfully.").as_ref());
|
2026-06-09 13:41:21 -04:00
|
|
|
}
|
2026-08-05 00:26:29 +03:00
|
|
|
self.refresh_properties();
|
|
|
|
|
}
|
|
|
|
|
CmdResult::ReplaceManyContinue(replacements) => {
|
2026-08-12 12:46:41 +03:00
|
|
|
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();
|
|
|
|
|
}
|
2026-08-05 00:26:29 +03:00
|
|
|
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);
|
|
|
|
|
}
|
2026-03-22 22:44:32 +03:00
|
|
|
self.refresh_properties();
|
|
|
|
|
}
|
|
|
|
|
CmdResult::ReplaceEntity(handle, new_entities) => {
|
2026-08-12 12:46:41 +03:00
|
|
|
if self.reject_locked_edit(i, handle) {
|
|
|
|
|
return Task::none();
|
|
|
|
|
}
|
2026-04-08 00:16:44 +03:00
|
|
|
// 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);
|
2026-06-16 11:49:14 +03:00
|
|
|
crate::modules::draw::modify::splinedit::apply_spline_op(
|
2026-04-08 00:16:44 +03:00
|
|
|
&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);
|
|
|
|
|
}
|
2026-04-08 00:16:44 +03:00
|
|
|
return Task::none();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
feat: add missing Annotate tab commands and icons
New dimension commands: QDIM, DIMEDIT, DIMTEDIT, DIMBREAK, DIMSPACE,
DIMJOGLINE with full command workflows and SVG icons.
New multileader commands: MLEADERADD, MLEADERREMOVE, MLEADERALIGN,
MLEADERCOLLECT with entity injection pattern for in-place editing.
Updated annotate ribbon: all tools exposed across Text, Dimensions,
Leaders, Tables, Markup, and Annotation Scaling groups. Added STYLE,
FIND, DIMSTYLE shortcut buttons.
Added `inject_picked_entity` to the CadCommand trait for commands that
need a cloned copy of the picked entity to modify (DIMTEDIT, MLEADERADD,
MLEADERREMOVE). Sentinel-based dispatch in cmd_result.rs handles
DIMBREAK, DIMSPACE, DIMJOGLINE, MLEADERALIGN, and MLEADERCOLLECT.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 03:26:47 +03:00
|
|
|
// 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__")
|
|
|
|
|
{
|
2026-06-27 17:56:01 +03:00
|
|
|
// 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());
|
feat: add missing Annotate tab commands and icons
New dimension commands: QDIM, DIMEDIT, DIMTEDIT, DIMBREAK, DIMSPACE,
DIMJOGLINE with full command workflows and SVG icons.
New multileader commands: MLEADERADD, MLEADERREMOVE, MLEADERALIGN,
MLEADERCOLLECT with entity injection pattern for in-place editing.
Updated annotate ribbon: all tools exposed across Text, Dimensions,
Leaders, Tables, Markup, and Annotation Scaling groups. Added STYLE,
FIND, DIMSTYLE shortcut buttons.
Added `inject_picked_entity` to the CadCommand trait for commands that
need a cloned copy of the picked entity to modify (DIMTEDIT, MLEADERADD,
MLEADERREMOVE). Sentinel-based dispatch in cmd_result.rs handles
DIMBREAK, DIMSPACE, DIMJOGLINE, MLEADERALIGN, and MLEADERCOLLECT.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 03:26:47 +03:00
|
|
|
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());
|
feat: add missing Annotate tab commands and icons
New dimension commands: QDIM, DIMEDIT, DIMTEDIT, DIMBREAK, DIMSPACE,
DIMJOGLINE with full command workflows and SVG icons.
New multileader commands: MLEADERADD, MLEADERREMOVE, MLEADERALIGN,
MLEADERCOLLECT with entity injection pattern for in-place editing.
Updated annotate ribbon: all tools exposed across Text, Dimensions,
Leaders, Tables, Markup, and Annotation Scaling groups. Added STYLE,
FIND, DIMSTYLE shortcut buttons.
Added `inject_picked_entity` to the CadCommand trait for commands that
need a cloned copy of the picked entity to modify (DIMTEDIT, MLEADERADD,
MLEADERREMOVE). Sentinel-based dispatch in cmd_result.rs handles
DIMBREAK, DIMSPACE, DIMJOGLINE, MLEADERALIGN, and MLEADERCOLLECT.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 03:26:47 +03:00
|
|
|
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__") {
|
2026-06-27 17:56:01 +03:00
|
|
|
// 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());
|
feat: add missing Annotate tab commands and icons
New dimension commands: QDIM, DIMEDIT, DIMTEDIT, DIMBREAK, DIMSPACE,
DIMJOGLINE with full command workflows and SVG icons.
New multileader commands: MLEADERADD, MLEADERREMOVE, MLEADERALIGN,
MLEADERCOLLECT with entity injection pattern for in-place editing.
Updated annotate ribbon: all tools exposed across Text, Dimensions,
Leaders, Tables, Markup, and Annotation Scaling groups. Added STYLE,
FIND, DIMSTYLE shortcut buttons.
Added `inject_picked_entity` to the CadCommand trait for commands that
need a cloned copy of the picked entity to modify (DIMTEDIT, MLEADERADD,
MLEADERREMOVE). Sentinel-based dispatch in cmd_result.rs handles
DIMBREAK, DIMSPACE, DIMJOGLINE, MLEADERALIGN, and MLEADERCOLLECT.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 03:26:47 +03:00
|
|
|
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());
|
feat: add missing Annotate tab commands and icons
New dimension commands: QDIM, DIMEDIT, DIMTEDIT, DIMBREAK, DIMSPACE,
DIMJOGLINE with full command workflows and SVG icons.
New multileader commands: MLEADERADD, MLEADERREMOVE, MLEADERALIGN,
MLEADERCOLLECT with entity injection pattern for in-place editing.
Updated annotate ribbon: all tools exposed across Text, Dimensions,
Leaders, Tables, Markup, and Annotation Scaling groups. Added STYLE,
FIND, DIMSTYLE shortcut buttons.
Added `inject_picked_entity` to the CadCommand trait for commands that
need a cloned copy of the picked entity to modify (DIMTEDIT, MLEADERADD,
MLEADERREMOVE). Sentinel-based dispatch in cmd_result.rs handles
DIMBREAK, DIMSPACE, DIMJOGLINE, MLEADERALIGN, and MLEADERCOLLECT.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 03:26:47 +03:00
|
|
|
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());
|
feat: add missing Annotate tab commands and icons
New dimension commands: QDIM, DIMEDIT, DIMTEDIT, DIMBREAK, DIMSPACE,
DIMJOGLINE with full command workflows and SVG icons.
New multileader commands: MLEADERADD, MLEADERREMOVE, MLEADERALIGN,
MLEADERCOLLECT with entity injection pattern for in-place editing.
Updated annotate ribbon: all tools exposed across Text, Dimensions,
Leaders, Tables, Markup, and Annotation Scaling groups. Added STYLE,
FIND, DIMSTYLE shortcut buttons.
Added `inject_picked_entity` to the CadCommand trait for commands that
need a cloned copy of the picked entity to modify (DIMTEDIT, MLEADERADD,
MLEADERREMOVE). Sentinel-based dispatch in cmd_result.rs handles
DIMBREAK, DIMSPACE, DIMJOGLINE, MLEADERALIGN, and MLEADERCOLLECT.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 03:26:47 +03:00
|
|
|
self.tabs[i].dirty = true;
|
|
|
|
|
self.tabs[i].active_cmd = None;
|
|
|
|
|
self.tabs[i].snap_result = None;
|
|
|
|
|
return Task::none();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-22 22:44:32 +03:00
|
|
|
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();
|
2026-07-08 10:23:48 +03:00
|
|
|
// 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(_))
|
|
|
|
|
) {
|
2026-07-24 23:25:33 +03:00
|
|
|
self.tabs[i].scene.invalidate_dim_block_recorded(nh);
|
2026-07-08 10:23:48 +03:00
|
|
|
}
|
|
|
|
|
}
|
2026-03-22 22:44:32 +03:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-08 00:33:56 +03:00
|
|
|
CmdResult::AttreqNeeded { block_name } => {
|
2026-07-03 16:47:39 +03:00
|
|
|
// Collect the full AttributeDefinitions owned by this block
|
|
|
|
|
// record so each created attribute keeps its geometry (#255).
|
|
|
|
|
let attdefs: Vec<acadrust::entities::AttributeDefinition> = {
|
2026-04-08 00:33:56 +03:00
|
|
|
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)
|
|
|
|
|
{
|
2026-07-03 16:47:39 +03:00
|
|
|
Some(ad.clone())
|
2026-05-12 10:36:15 +03:00
|
|
|
} else {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.collect()
|
2026-04-08 00:33:56 +03:00
|
|
|
} 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()
|
2026-04-08 00:33:56 +03:00
|
|
|
.and_then(|c| c.attreq_take_insert());
|
|
|
|
|
if let Some(entity) = entity {
|
|
|
|
|
let label = self.history_label_from_active_cmd(i, "INSERT");
|
2026-07-25 01:06:00 +03:00
|
|
|
let delta_safe = self.delta_add_safe(i, &entity);
|
|
|
|
|
let pending = self.begin_undo(i, label, 1, delta_safe);
|
2026-04-08 00:33:56 +03:00
|
|
|
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();
|
2026-07-25 01:06:00 +03:00
|
|
|
if let Some(pd) = pending {
|
|
|
|
|
self.commit_undo_delta(i, pd);
|
|
|
|
|
}
|
2026-04-08 00:33:56 +03:00
|
|
|
}
|
|
|
|
|
} 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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-18 21:02:50 +03:00
|
|
|
CmdResult::CommitLiveEntity(entity) => {
|
|
|
|
|
let label = self.history_label_from_active_cmd(i, "ENTITY");
|
2026-07-25 01:06:00 +03:00
|
|
|
let delta_safe = self.delta_add_safe(i, &entity);
|
|
|
|
|
let pending = self.begin_undo(i, label, 1, delta_safe);
|
2026-06-18 21:02:50 +03:00
|
|
|
let handle = self.commit_entity_handle(entity);
|
|
|
|
|
self.tabs[i].dirty = true;
|
|
|
|
|
self.tabs[i].scene.clear_preview_wire();
|
2026-07-25 01:06:00 +03:00
|
|
|
if let Some(pd) = pending {
|
|
|
|
|
self.commit_undo_delta(i, pd);
|
|
|
|
|
}
|
2026-06-18 21:02:50 +03:00
|
|
|
if let Some(h) = handle {
|
2026-07-25 01:06:00 +03:00
|
|
|
// 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);
|
2026-06-18 21:02:50 +03:00
|
|
|
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,
|
|
|
|
|
} => {
|
2026-08-01 19:09:19 +03:00
|
|
|
let tracks_draw_anchor = matches!(
|
|
|
|
|
&entity,
|
|
|
|
|
acadrust::EntityType::Line(_)
|
|
|
|
|
| acadrust::EntityType::Arc(_)
|
|
|
|
|
| acadrust::EntityType::LwPolyline(_)
|
|
|
|
|
| acadrust::EntityType::Polyline(_)
|
|
|
|
|
| acadrust::EntityType::Polyline2D(_)
|
|
|
|
|
| acadrust::EntityType::Polyline3D(_)
|
|
|
|
|
);
|
2026-06-18 21:02:50 +03:00
|
|
|
// 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;
|
2026-07-25 01:06:00 +03:00
|
|
|
self.tabs[i]
|
|
|
|
|
.scene
|
|
|
|
|
.bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]);
|
2026-06-18 21:02:50 +03:00
|
|
|
self.tabs[i].dirty = true;
|
|
|
|
|
}
|
2026-08-01 19:09:19 +03:00
|
|
|
if tracks_draw_anchor {
|
|
|
|
|
self.tabs[i].last_draw_anchor = Some(handle);
|
|
|
|
|
}
|
2026-06-18 21:02:50 +03:00
|
|
|
if finish {
|
2026-07-25 01:06:00 +03:00
|
|
|
self.finish_live_entity_history(i, handle);
|
2026-06-18 21:02:50 +03:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-28 17:16:27 +03:00
|
|
|
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();
|
|
|
|
|
}
|
2026-07-22 11:17:01 +03:00
|
|
|
CmdResult::RemoveLiveEntity(handle) => {
|
|
|
|
|
// The command backed off below a valid entity (PLINE Undo at
|
|
|
|
|
// one remaining vertex): take the live entity out of the
|
2026-07-25 01:06:00 +03:00
|
|
|
// document, drop its provisional history entry and keep
|
|
|
|
|
// prompting. A later second point creates one fresh entry.
|
2026-07-22 11:17:01 +03:00
|
|
|
self.tabs[i].scene.erase_entities(&[handle]);
|
2026-08-01 19:09:19 +03:00
|
|
|
if self.tabs[i]
|
|
|
|
|
.last_draw_anchor
|
|
|
|
|
.is_some_and(|anchor_handle| anchor_handle == handle)
|
|
|
|
|
{
|
|
|
|
|
self.tabs[i].last_draw_anchor = None;
|
|
|
|
|
}
|
2026-07-25 01:06:00 +03:00
|
|
|
self.discard_last_undo_entry(i);
|
2026-07-22 11:17:01 +03:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-28 18:51:20 +03:00
|
|
|
cancel @ (CmdResult::Cancel | CmdResult::CancelForSpaceChange) => {
|
|
|
|
|
let space_changed = matches!(cancel, CmdResult::CancelForSpaceChange);
|
2026-03-22 22:44:32 +03:00
|
|
|
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-07-28 18:51:20 +03:00
|
|
|
self.command_line.push_info(if space_changed {
|
|
|
|
|
"Command cancelled because the active drawing space changed."
|
|
|
|
|
} else {
|
|
|
|
|
"Command cancelled."
|
|
|
|
|
});
|
2026-03-22 22:44:32 +03:00
|
|
|
}
|
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));
|
|
|
|
|
}
|
2026-03-22 22:44:32 +03:00
|
|
|
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);
|
|
|
|
|
}
|
2026-06-25 12:36:49 +03:00
|
|
|
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);
|
|
|
|
|
}
|
2026-03-22 22:44:32 +03:00
|
|
|
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
|
2026-03-22 22:44:32 +03:00
|
|
|
.get_entity(src)
|
|
|
|
|
.map(|e| e.common().layer.clone());
|
2026-08-12 12:46:41 +03:00
|
|
|
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 {
|
2026-03-22 22:44:32 +03:00
|
|
|
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());
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-01 19:14:35 +03:00
|
|
|
// 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);
|
2026-03-22 22:44:32 +03:00
|
|
|
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());
|
2026-03-22 22:44:32 +03:00
|
|
|
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());
|
2026-03-22 22:44:32 +03:00
|
|
|
}
|
|
|
|
|
}
|
2026-08-12 12:46:41 +03:00
|
|
|
CmdResult::MatchProperties { mut dest, src } => {
|
|
|
|
|
dest.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle));
|
|
|
|
|
if dest.is_empty() {
|
|
|
|
|
return Task::none();
|
|
|
|
|
}
|
2026-07-21 23:22:27 +03:00
|
|
|
// The command stays active after each apply so more targets
|
|
|
|
|
// can keep being picked; Enter / Esc ends it (#362).
|
feat(matchprop): match every style-affecting property (#281)
MATCHPROP copied only the general properties (and text style/height),
so matching dimension-to-dimension or hatch-to-hatch looked like it did
nothing — the visible differences live in type-specific style fields.
General set gains transparency. match_special_props transfers every
STYLE-affecting field the destination supports — never content or
placement:
- Text: style, height, width factor, oblique (style+height cross
TEXT <-> MTEXT); MText adds line spacing, drawing direction and the
background fill block.
- Dimension / Leader / Tolerance: the dim style name crosses all
three, and the ACAD_DSTYLE override record is replicated through
set_entity_xdata (cleared on the destination when the source carries
none, so the match is exact). Restyled dimensions drop their baked
*D block and repaint immediately.
- Hatch: pattern definition, type, angle, scale, solid flag and
gradient — the boundary stays.
- LwPolyline: constant width (vertex widths included) and linetype
generation.
- MultiLeader: style plus all style overrides (path type, line
color/type/weight, landing/dogleg, arrowhead, text style/color/
frame/height, overall scale, override flags); leader geometry, text
and block content stay.
Closes #281
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:24:53 +03:00
|
|
|
// 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();
|
2026-07-28 19:53:39 +03:00
|
|
|
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,
|
|
|
|
|
};
|
2026-07-24 19:04:57 +03:00
|
|
|
// 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
|
feat(matchprop): match every style-affecting property (#281)
MATCHPROP copied only the general properties (and text style/height),
so matching dimension-to-dimension or hatch-to-hatch looked like it did
nothing — the visible differences live in type-specific style fields.
General set gains transparency. match_special_props transfers every
STYLE-affecting field the destination supports — never content or
placement:
- Text: style, height, width factor, oblique (style+height cross
TEXT <-> MTEXT); MText adds line spacing, drawing direction and the
background fill block.
- Dimension / Leader / Tolerance: the dim style name crosses all
three, and the ACAD_DSTYLE override record is replicated through
set_entity_xdata (cleared on the destination when the source carries
none, so the match is exact). Restyled dimensions drop their baked
*D block and repaint immediately.
- Hatch: pattern definition, type, angle, scale, solid flag and
gradient — the boundary stays.
- LwPolyline: constant width (vertex widths included) and linetype
generation.
- MultiLeader: style plus all style overrides (path type, line
color/type/weight, landing/dogleg, arrowhead, text style/color/
frame/height, overall scale, override flags); leader geometry, text
and block content stay.
Closes #281
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:24:53 +03:00
|
|
|
.as_ref()
|
|
|
|
|
.filter(|e| {
|
|
|
|
|
matches!(
|
|
|
|
|
e,
|
|
|
|
|
acadrust::EntityType::Dimension(_) | acadrust::EntityType::Leader(_)
|
|
|
|
|
)
|
|
|
|
|
})
|
2026-07-24 19:04:57 +03:00
|
|
|
.map(|e| crate::entities::dim_override::pairs(&e.common().extended_data));
|
2026-03-22 22:44:32 +03:00
|
|
|
|
2026-07-28 19:53:39 +03:00
|
|
|
if let Some(common) = src_common {
|
2026-03-22 22:44:32 +03:00
|
|
|
self.push_undo_snapshot(i, "MATCHPROP");
|
|
|
|
|
for h in &dest {
|
2026-07-28 19:53:39 +03:00
|
|
|
let mut is_dim = false;
|
|
|
|
|
let mut is_hatch = false;
|
2026-03-22 22:44:32 +03:00
|
|
|
if let Some(e) = self.tabs[i].scene.document.get_entity_mut(*h) {
|
2026-07-28 19:53:39 +03:00
|
|
|
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);
|
feat(matchprop): match every style-affecting property (#281)
MATCHPROP copied only the general properties (and text style/height),
so matching dimension-to-dimension or hatch-to-hatch looked like it did
nothing — the visible differences live in type-specific style fields.
General set gains transparency. match_special_props transfers every
STYLE-affecting field the destination supports — never content or
placement:
- Text: style, height, width factor, oblique (style+height cross
TEXT <-> MTEXT); MText adds line spacing, drawing direction and the
background fill block.
- Dimension / Leader / Tolerance: the dim style name crosses all
three, and the ACAD_DSTYLE override record is replicated through
set_entity_xdata (cleared on the destination when the source carries
none, so the match is exact). Restyled dimensions drop their baked
*D block and repaint immediately.
- Hatch: pattern definition, type, angle, scale, solid flag and
gradient — the boundary stays.
- LwPolyline: constant width (vertex widths included) and linetype
generation.
- MultiLeader: style plus all style overrides (path type, line
color/type/weight, landing/dogleg, arrowhead, text style/color/
frame/height, overall scale, override flags); leader geometry, text
and block content stay.
Closes #281
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:24:53 +03:00
|
|
|
}
|
|
|
|
|
if let Some(se) = &src_clone {
|
2026-07-28 19:53:39 +03:00
|
|
|
is_dim = matches!(e, acadrust::EntityType::Dimension(_));
|
|
|
|
|
is_hatch = matches!(e, acadrust::EntityType::Hatch(_));
|
feat(matchprop): match every style-affecting property (#281)
MATCHPROP copied only the general properties (and text style/height),
so matching dimension-to-dimension or hatch-to-hatch looked like it did
nothing — the visible differences live in type-specific style fields.
General set gains transparency. match_special_props transfers every
STYLE-affecting field the destination supports — never content or
placement:
- Text: style, height, width factor, oblique (style+height cross
TEXT <-> MTEXT); MText adds line spacing, drawing direction and the
background fill block.
- Dimension / Leader / Tolerance: the dim style name crosses all
three, and the ACAD_DSTYLE override record is replicated through
set_entity_xdata (cleared on the destination when the source carries
none, so the match is exact). Restyled dimensions drop their baked
*D block and repaint immediately.
- Hatch: pattern definition, type, angle, scale, solid flag and
gradient — the boundary stays.
- LwPolyline: constant width (vertex widths included) and linetype
generation.
- MultiLeader: style plus all style overrides (path type, line
color/type/weight, landing/dogleg, arrowhead, text style/color/
frame/height, overall scale, override flags); leader geometry, text
and block content stay.
Closes #281
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:24:53 +03:00
|
|
|
match_special_props(se, e);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-28 19:53:39 +03:00
|
|
|
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(),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-24 19:04:57 +03:00
|
|
|
// Dim-style overrides follow the style for dimension /
|
|
|
|
|
// leader destinations — through set_entity_xdata so no
|
|
|
|
|
// stale raw record survives.
|
feat(matchprop): match every style-affecting property (#281)
MATCHPROP copied only the general properties (and text style/height),
so matching dimension-to-dimension or hatch-to-hatch looked like it did
nothing — the visible differences live in type-specific style fields.
General set gains transparency. match_special_props transfers every
STYLE-affecting field the destination supports — never content or
placement:
- Text: style, height, width factor, oblique (style+height cross
TEXT <-> MTEXT); MText adds line spacing, drawing direction and the
background fill block.
- Dimension / Leader / Tolerance: the dim style name crosses all
three, and the ACAD_DSTYLE override record is replicated through
set_entity_xdata (cleared on the destination when the source carries
none, so the match is exact). Restyled dimensions drop their baked
*D block and repaint immediately.
- Hatch: pattern definition, type, angle, scale, solid flag and
gradient — the boundary stays.
- LwPolyline: constant width (vertex widths included) and linetype
generation.
- MultiLeader: style plus all style overrides (path type, line
color/type/weight, landing/dogleg, arrowhead, text style/color/
frame/height, overall scale, override flags); leader geometry, text
and block content stay.
Closes #281
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:24:53 +03:00
|
|
|
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(_)
|
|
|
|
|
)
|
|
|
|
|
) {
|
2026-07-24 19:04:57 +03:00
|
|
|
crate::entities::dim_override::replace(
|
feat(matchprop): match every style-affecting property (#281)
MATCHPROP copied only the general properties (and text style/height),
so matching dimension-to-dimension or hatch-to-hatch looked like it did
nothing — the visible differences live in type-specific style fields.
General set gains transparency. match_special_props transfers every
STYLE-affecting field the destination supports — never content or
placement:
- Text: style, height, width factor, oblique (style+height cross
TEXT <-> MTEXT); MText adds line spacing, drawing direction and the
background fill block.
- Dimension / Leader / Tolerance: the dim style name crosses all
three, and the ACAD_DSTYLE override record is replicated through
set_entity_xdata (cleared on the destination when the source carries
none, so the match is exact). Restyled dimensions drop their baked
*D block and repaint immediately.
- Hatch: pattern definition, type, angle, scale, solid flag and
gradient — the boundary stays.
- LwPolyline: constant width (vertex widths included) and linetype
generation.
- MultiLeader: style plus all style overrides (path type, line
color/type/weight, landing/dogleg, arrowhead, text style/color/
frame/height, overall scale, override flags); leader geometry, text
and block content stay.
Closes #281
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:24:53 +03:00
|
|
|
&mut self.tabs[i].scene.document,
|
|
|
|
|
*h,
|
2026-07-24 19:04:57 +03:00
|
|
|
dstyle_xdata.clone().unwrap_or_default(),
|
feat(matchprop): match every style-affecting property (#281)
MATCHPROP copied only the general properties (and text style/height),
so matching dimension-to-dimension or hatch-to-hatch looked like it did
nothing — the visible differences live in type-specific style fields.
General set gains transparency. match_special_props transfers every
STYLE-affecting field the destination supports — never content or
placement:
- Text: style, height, width factor, oblique (style+height cross
TEXT <-> MTEXT); MText adds line spacing, drawing direction and the
background fill block.
- Dimension / Leader / Tolerance: the dim style name crosses all
three, and the ACAD_DSTYLE override record is replicated through
set_entity_xdata (cleared on the destination when the source carries
none, so the match is exact). Restyled dimensions drop their baked
*D block and repaint immediately.
- Hatch: pattern definition, type, angle, scale, solid flag and
gradient — the boundary stays.
- LwPolyline: constant width (vertex widths included) and linetype
generation.
- MultiLeader: style plus all style overrides (path type, line
color/type/weight, landing/dogleg, arrowhead, text style/color/
frame/height, overall scale, override flags); leader geometry, text
and block content stay.
Closes #281
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:24:53 +03:00
|
|
|
);
|
2026-07-14 18:22:48 -07:00
|
|
|
}
|
2026-03-22 22:44:32 +03:00
|
|
|
}
|
feat(matchprop): match every style-affecting property (#281)
MATCHPROP copied only the general properties (and text style/height),
so matching dimension-to-dimension or hatch-to-hatch looked like it did
nothing — the visible differences live in type-specific style fields.
General set gains transparency. match_special_props transfers every
STYLE-affecting field the destination supports — never content or
placement:
- Text: style, height, width factor, oblique (style+height cross
TEXT <-> MTEXT); MText adds line spacing, drawing direction and the
background fill block.
- Dimension / Leader / Tolerance: the dim style name crosses all
three, and the ACAD_DSTYLE override record is replicated through
set_entity_xdata (cleared on the destination when the source carries
none, so the match is exact). Restyled dimensions drop their baked
*D block and repaint immediately.
- Hatch: pattern definition, type, angle, scale, solid flag and
gradient — the boundary stays.
- LwPolyline: constant width (vertex widths included) and linetype
generation.
- MultiLeader: style plus all style overrides (path type, line
color/type/weight, landing/dogleg, arrowhead, text style/color/
frame/height, overall scale, override flags); leader geometry, text
and block content stay.
Closes #281
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:24:53 +03:00
|
|
|
// A restyled dimension renders from its baked *D block —
|
|
|
|
|
// drop the stale block so the new style shows (#398).
|
2026-07-28 19:53:39 +03:00
|
|
|
if is_dim {
|
2026-07-24 23:25:33 +03:00
|
|
|
self.tabs[i].scene.invalidate_dim_block_recorded(*h);
|
feat(matchprop): match every style-affecting property (#281)
MATCHPROP copied only the general properties (and text style/height),
so matching dimension-to-dimension or hatch-to-hatch looked like it did
nothing — the visible differences live in type-specific style fields.
General set gains transparency. match_special_props transfers every
STYLE-affecting field the destination supports — never content or
placement:
- Text: style, height, width factor, oblique (style+height cross
TEXT <-> MTEXT); MText adds line spacing, drawing direction and the
background fill block.
- Dimension / Leader / Tolerance: the dim style name crosses all
three, and the ACAD_DSTYLE override record is replicated through
set_entity_xdata (cleared on the destination when the source carries
none, so the match is exact). Restyled dimensions drop their baked
*D block and repaint immediately.
- Hatch: pattern definition, type, angle, scale, solid flag and
gradient — the boundary stays.
- LwPolyline: constant width (vertex widths included) and linetype
generation.
- MultiLeader: style plus all style overrides (path type, line
color/type/weight, landing/dogleg, arrowhead, text style/color/
frame/height, overall scale, override flags); leader geometry, text
and block content stay.
Closes #281
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:24:53 +03:00
|
|
|
}
|
|
|
|
|
// Hatch fills render from a prebuilt model (#415).
|
|
|
|
|
self.tabs[i].scene.refresh_fill_model(*h);
|
2026-03-22 22:44:32 +03:00
|
|
|
}
|
|
|
|
|
self.tabs[i].dirty = true;
|
2026-06-30 23:41:13 +03:00
|
|
|
// Color / linetype / lineweight are baked into the cached
|
2026-07-28 17:16:27 +03:00
|
|
|
// 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);
|
2026-03-22 22:44:32 +03:00
|
|
|
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());
|
2026-07-21 23:22:27 +03:00
|
|
|
// 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());
|
|
|
|
|
}
|
2026-03-22 22:44:32 +03:00
|
|
|
} else {
|
2026-08-02 22:09:25 +03:00
|
|
|
self.command_line.push_error(crate::t!("Source object not found.").as_ref());
|
2026-07-21 23:22:27 +03:00
|
|
|
self.tabs[i].active_cmd = None;
|
|
|
|
|
self.tabs[i].snap_result = None;
|
|
|
|
|
self.tabs[i].scene.clear_preview_wire();
|
2026-03-22 22:44:32 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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());
|
2026-03-22 22:44:32 +03:00
|
|
|
} else {
|
2026-07-10 11:16:13 +03:00
|
|
|
let delta = base_pt - self.clipboard_base;
|
2026-03-22 22:44:32 +03:00
|
|
|
let translate = crate::command::EntityTransform::Translate(delta);
|
|
|
|
|
self.push_undo_snapshot(i, "PASTECLIP");
|
|
|
|
|
let count = self.clipboard.len();
|
2026-06-24 09:53:28 +03:00
|
|
|
let by_index = self.finalize_paste(i, Some(translate));
|
2026-03-22 22:44:32 +03:00
|
|
|
self.tabs[i].scene.deselect_all();
|
2026-06-24 09:33:55 +03:00
|
|
|
for h in by_index.iter().copied().filter(|h| !h.is_null()) {
|
2026-03-22 22:44:32 +03:00
|
|
|
self.tabs[i].scene.select_entity(h, false);
|
|
|
|
|
}
|
|
|
|
|
self.tabs[i].dirty = true;
|
2026-06-21 02:12:58 +03:00
|
|
|
// Surface any layers the paste brought in (cross-drawing)
|
|
|
|
|
// in the layer manager and the layer dropdown.
|
|
|
|
|
self.refresh_layer_panel();
|
2026-03-22 22:44:32 +03:00
|
|
|
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());
|
2026-03-22 22:44:32 +03:00
|
|
|
}
|
|
|
|
|
}
|
2026-08-12 12:46:41 +03:00
|
|
|
CmdResult::CreateGroup { mut handles, name } => {
|
|
|
|
|
handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle));
|
2026-03-22 22:44:32 +03:00
|
|
|
self.tabs[i].active_cmd = None;
|
|
|
|
|
self.tabs[i].snap_result = None;
|
|
|
|
|
self.tabs[i].scene.clear_preview_wire();
|
2026-08-12 12:46:41 +03:00
|
|
|
if handles.is_empty() {
|
|
|
|
|
return Task::none();
|
|
|
|
|
}
|
2026-07-25 18:49:18 +03:00
|
|
|
let undo = self.begin_group_undo(i, "GROUP");
|
2026-03-22 22:44:32 +03:00
|
|
|
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());
|
2026-03-22 22:44:32 +03:00
|
|
|
}
|
2026-08-12 12:46:41 +03:00
|
|
|
CmdResult::DeleteGroups { mut handles } => {
|
|
|
|
|
handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle));
|
2026-03-22 22:44:32 +03:00
|
|
|
self.tabs[i].active_cmd = None;
|
|
|
|
|
self.tabs[i].snap_result = None;
|
|
|
|
|
self.tabs[i].scene.clear_preview_wire();
|
2026-08-12 12:46:41 +03:00
|
|
|
if handles.is_empty() {
|
|
|
|
|
return Task::none();
|
|
|
|
|
}
|
2026-07-25 18:49:18 +03:00
|
|
|
let undo = self.begin_group_undo(i, "UNGROUP");
|
2026-03-22 22:44:32 +03:00
|
|
|
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);
|
2026-03-22 22:44:32 +03:00
|
|
|
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());
|
2026-03-22 22:44:32 +03:00
|
|
|
} 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-03-22 22:44:32 +03:00
|
|
|
}
|
|
|
|
|
}
|
2026-05-12 10:36:15 +03:00
|
|
|
CmdResult::VpLayerUpdate {
|
|
|
|
|
vp_handle,
|
|
|
|
|
freeze,
|
|
|
|
|
thaw,
|
|
|
|
|
} => {
|
2026-04-02 23:13:09 +03:00
|
|
|
// 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()
|
Feat: viewport display lock, extended properties, VPLAYER and VPORTS commands
- Enforce status.locked in pan_active_viewport and zoom_active_viewport:
locked viewports now reject pan/zoom inputs entirely
- Add perspective, render_mode, shade_plot_mode, hide_plot, ucs_icon_visible
fields to the viewport property panel with appropriate pickers/toggles
- Add VPORTS command: lists all user viewports in the current layout
(id, size, position, scale, on/locked state) in the command line
- Add VPLAYER command: per-viewport layer freeze/thaw from the command line
(F <layer> to freeze, T <layer> to thaw, Enter to exit)
- Add CmdResult::VpLayerUpdate variant and its handler in apply_cmd_result
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 23:52:18 +03:00
|
|
|
.filter_map(|name| {
|
2026-05-12 10:36:15 +03:00
|
|
|
self.tabs[i]
|
|
|
|
|
.scene
|
|
|
|
|
.document
|
|
|
|
|
.layers
|
|
|
|
|
.iter()
|
Feat: viewport display lock, extended properties, VPLAYER and VPORTS commands
- Enforce status.locked in pan_active_viewport and zoom_active_viewport:
locked viewports now reject pan/zoom inputs entirely
- Add perspective, render_mode, shade_plot_mode, hide_plot, ucs_icon_visible
fields to the viewport property panel with appropriate pickers/toggles
- Add VPORTS command: lists all user viewports in the current layout
(id, size, position, scale, on/locked state) in the command line
- Add VPLAYER command: per-viewport layer freeze/thaw from the command line
(F <layer> to freeze, T <layer> to thaw, Enter to exit)
- Add CmdResult::VpLayerUpdate variant and its handler in apply_cmd_result
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 23:52:18 +03:00
|
|
|
.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()
|
Feat: viewport display lock, extended properties, VPLAYER and VPORTS commands
- Enforce status.locked in pan_active_viewport and zoom_active_viewport:
locked viewports now reject pan/zoom inputs entirely
- Add perspective, render_mode, shade_plot_mode, hide_plot, ucs_icon_visible
fields to the viewport property panel with appropriate pickers/toggles
- Add VPORTS command: lists all user viewports in the current layout
(id, size, position, scale, on/locked state) in the command line
- Add VPLAYER command: per-viewport layer freeze/thaw from the command line
(F <layer> to freeze, T <layer> to thaw, Enter to exit)
- Add CmdResult::VpLayerUpdate variant and its handler in apply_cmd_result
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 23:52:18 +03:00
|
|
|
.filter_map(|name| {
|
2026-05-12 10:36:15 +03:00
|
|
|
self.tabs[i]
|
|
|
|
|
.scene
|
|
|
|
|
.document
|
|
|
|
|
.layers
|
|
|
|
|
.iter()
|
Feat: viewport display lock, extended properties, VPLAYER and VPORTS commands
- Enforce status.locked in pan_active_viewport and zoom_active_viewport:
locked viewports now reject pan/zoom inputs entirely
- Add perspective, render_mode, shade_plot_mode, hide_plot, ucs_icon_visible
fields to the viewport property panel with appropriate pickers/toggles
- Add VPORTS command: lists all user viewports in the current layout
(id, size, position, scale, on/locked state) in the command line
- Add VPLAYER command: per-viewport layer freeze/thaw from the command line
(F <layer> to freeze, T <layer> to thaw, Enter to exit)
- Add CmdResult::VpLayerUpdate variant and its handler in apply_cmd_result
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 23:52:18 +03:00
|
|
|
.find(|l| l.name.eq_ignore_ascii_case(name))
|
|
|
|
|
.map(|l| l.handle)
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
let mut frozen_count = 0usize;
|
|
|
|
|
let mut thawed_count = 0usize;
|
|
|
|
|
|
2026-04-02 23:13:09 +03:00
|
|
|
// 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()
|
2026-04-02 23:13:09 +03:00
|
|
|
.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;
|
|
|
|
|
}
|
Feat: viewport display lock, extended properties, VPLAYER and VPORTS commands
- Enforce status.locked in pan_active_viewport and zoom_active_viewport:
locked viewports now reject pan/zoom inputs entirely
- Add perspective, render_mode, shade_plot_mode, hide_plot, ucs_icon_visible
fields to the viewport property panel with appropriate pickers/toggles
- Add VPORTS command: lists all user viewports in the current layout
(id, size, position, scale, on/locked state) in the command line
- Add VPLAYER command: per-viewport layer freeze/thaw from the command line
(F <layer> to freeze, T <layer> to thaw, Enter to exit)
- Add CmdResult::VpLayerUpdate variant and its handler in apply_cmd_result
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 23:52:18 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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());
|
Feat: viewport display lock, extended properties, VPLAYER and VPORTS commands
- Enforce status.locked in pan_active_viewport and zoom_active_viewport:
locked viewports now reject pan/zoom inputs entirely
- Add perspective, render_mode, shade_plot_mode, hide_plot, ucs_icon_visible
fields to the viewport property panel with appropriate pickers/toggles
- Add VPORTS command: lists all user viewports in the current layout
(id, size, position, scale, on/locked state) in the command line
- Add VPLAYER command: per-viewport layer freeze/thaw from the command line
(F <layer> to freeze, T <layer> to thaw, Enter to exit)
- Add CmdResult::VpLayerUpdate variant and its handler in apply_cmd_result
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 23:52:18 +03:00
|
|
|
}
|
|
|
|
|
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());
|
Feat: viewport display lock, extended properties, VPLAYER and VPORTS commands
- Enforce status.locked in pan_active_viewport and zoom_active_viewport:
locked viewports now reject pan/zoom inputs entirely
- Add perspective, render_mode, shade_plot_mode, hide_plot, ucs_icon_visible
fields to the viewport property panel with appropriate pickers/toggles
- Add VPORTS command: lists all user viewports in the current layout
(id, size, position, scale, on/locked state) in the command line
- Add VPLAYER command: per-viewport layer freeze/thaw from the command line
(F <layer> to freeze, T <layer> to thaw, Enter to exit)
- Add CmdResult::VpLayerUpdate variant and its handler in apply_cmd_result
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 23:52:18 +03:00
|
|
|
}
|
2026-04-06 23:41:39 +03:00
|
|
|
// 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);
|
Feat: viewport display lock, extended properties, VPLAYER and VPORTS commands
- Enforce status.locked in pan_active_viewport and zoom_active_viewport:
locked viewports now reject pan/zoom inputs entirely
- Add perspective, render_mode, shade_plot_mode, hide_plot, ucs_icon_visible
fields to the viewport property panel with appropriate pickers/toggles
- Add VPORTS command: lists all user viewports in the current layout
(id, size, position, scale, on/locked state) in the command line
- Add VPLAYER command: per-viewport layer freeze/thaw from the command line
(F <layer> to freeze, T <layer> to thaw, Enter to exit)
- Add CmdResult::VpLayerUpdate variant and its handler in apply_cmd_result
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 23:52:18 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-02 23:28:00 +03:00
|
|
|
|
|
|
|
|
CmdResult::ZoomToWindow { p1, p2 } => {
|
|
|
|
|
self.tabs[i].active_cmd = None;
|
|
|
|
|
self.tabs[i].snap_result = None;
|
|
|
|
|
self.tabs[i].scene.clear_preview_wire();
|
2026-08-08 20:37:53 +03:00
|
|
|
self.tabs[i].scene.remember_current_view();
|
2026-07-24 20:49:05 +03:00
|
|
|
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());
|
2026-04-02 23:28:00 +03:00
|
|
|
}
|
2026-04-02 23:36:53 +03:00
|
|
|
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);
|
|
|
|
|
}
|
2026-08-03 22:31:20 +03:00
|
|
|
CmdResult::ReportMeasurement(msg) => {
|
|
|
|
|
self.tabs[i].snap_result = None;
|
|
|
|
|
self.tabs[i].scene.clear_preview_wire();
|
2026-08-04 00:01:12 +03:00
|
|
|
self.refresh_area_preview(i);
|
2026-08-03 22:31:20 +03:00
|
|
|
self.command_line.push_output(&msg);
|
|
|
|
|
if let Some(prompt) = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()) {
|
2026-08-04 00:01:12 +03:00
|
|
|
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()) {
|
2026-08-03 22:31:20 +03:00
|
|
|
self.command_line.push_info(&prompt);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-12 10:36:15 +03:00
|
|
|
CmdResult::AlignSelected {
|
2026-08-12 12:46:41 +03:00
|
|
|
mut handles,
|
2026-05-12 10:36:15 +03:00
|
|
|
src1,
|
|
|
|
|
dst1,
|
|
|
|
|
angle_rad,
|
|
|
|
|
scale,
|
|
|
|
|
} => {
|
2026-08-12 12:46:41 +03:00
|
|
|
handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle));
|
2026-04-02 23:46:48 +03:00
|
|
|
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);
|
2026-04-02 23:46:48 +03:00
|
|
|
// 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 {
|
2026-06-24 03:32:11 +03:00
|
|
|
center: glam::DVec3::ZERO,
|
2026-04-02 23:46:48 +03:00
|
|
|
factor: scale,
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
fix(commands): finish the world-XY coordinate migration (#54)
The drawing plane is world XY, but break, join, rectangular array, the
stretch/window move, the spline preview wire and several doc comments
still used the stale "Y-up XZ" mapping (world Z = DXF Y). That made
those commands operate against a constant-elevation axis: arc/circle/
line/spline break and join picked the wrong points, rectangular array
stepped rows along the Z (elevation) axis, window-stretch tested the
wrong window bound, and the spline wire drew in the wrong plane.
Map every drawing-plane coordinate through .y (elevation on .z),
matching the entity builders, the inquiry commands and the central
transform. Stale Y-up comments updated to avoid future confusion.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 10:39:24 +03:00
|
|
|
// Step 3: rotate in the XY plane by angle_rad
|
2026-04-02 23:46:48 +03:00
|
|
|
if angle_rad.abs() > 1e-4 {
|
|
|
|
|
self.tabs[i].scene.transform_entities(
|
|
|
|
|
&handles,
|
|
|
|
|
&crate::command::EntityTransform::Rotate {
|
2026-06-24 03:32:11 +03:00
|
|
|
center: glam::DVec3::ZERO,
|
2026-08-04 23:17:54 +03:00
|
|
|
axis: glam::DVec3::Z,
|
2026-04-02 23:46:48 +03:00
|
|
|
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());
|
2026-04-02 23:46:48 +03:00
|
|
|
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-04-02 23:46:48 +03:00
|
|
|
}
|
|
|
|
|
}
|
2026-05-12 10:36:15 +03:00
|
|
|
CmdResult::LengthenEntity {
|
|
|
|
|
handle,
|
|
|
|
|
pick_pt,
|
|
|
|
|
mode,
|
|
|
|
|
} => {
|
2026-08-12 12:46:41 +03:00
|
|
|
if self.reject_locked_edit(i, handle) {
|
|
|
|
|
return Task::none();
|
|
|
|
|
}
|
2026-06-16 11:49:14 +03:00
|
|
|
use crate::modules::draw::modify::lengthen::lengthen_entity;
|
2026-05-12 10:36:15 +03:00
|
|
|
let result = self.tabs[i]
|
|
|
|
|
.scene
|
|
|
|
|
.document
|
2026-04-02 23:45:06 +03:00
|
|
|
.get_entity(handle)
|
2026-06-24 03:32:11 +03:00
|
|
|
.and_then(|e| lengthen_entity(e, pick_pt.as_vec3(), &mode));
|
2026-04-02 23:45:06 +03:00
|
|
|
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());
|
2026-04-02 23:45:06 +03:00
|
|
|
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());
|
2026-04-02 23:45:06 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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-04-02 23:43:33 +03:00
|
|
|
CmdResult::DivideEntity { handle, n } => {
|
2026-06-16 11:49:14 +03:00
|
|
|
use crate::modules::draw::inquiry::divide::divide_entity;
|
2026-05-12 10:36:15 +03:00
|
|
|
let pts = self.tabs[i]
|
|
|
|
|
.scene
|
|
|
|
|
.document
|
2026-04-02 23:43:33 +03:00
|
|
|
.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);
|
|
|
|
|
}
|
2026-04-02 23:43:33 +03:00
|
|
|
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());
|
2026-04-02 23:43:33 +03:00
|
|
|
} 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());
|
2026-04-02 23:43:33 +03:00
|
|
|
}
|
|
|
|
|
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,
|
|
|
|
|
} => {
|
2026-06-16 11:49:14 +03:00
|
|
|
use crate::modules::draw::inquiry::divide::measure_entity;
|
2026-05-12 10:36:15 +03:00
|
|
|
let pts = self.tabs[i]
|
|
|
|
|
.scene
|
|
|
|
|
.document
|
2026-04-02 23:43:33 +03:00
|
|
|
.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);
|
|
|
|
|
}
|
2026-04-02 23:43:33 +03:00
|
|
|
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());
|
2026-04-02 23:43:33 +03:00
|
|
|
} 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());
|
2026-04-02 23:43:33 +03:00
|
|
|
}
|
|
|
|
|
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-04-03 09:19:36 +03:00
|
|
|
CmdResult::PeditOp { handle, op } => {
|
2026-08-12 12:46:41 +03:00
|
|
|
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) => {
|
2026-07-24 20:49:05 +03:00
|
|
|
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);
|
2026-07-28 17:16:27 +03:00
|
|
|
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 {
|
2026-07-24 23:25:33 +03:00
|
|
|
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);
|
2026-04-03 09:19:36 +03:00
|
|
|
}
|
|
|
|
|
}
|
2026-04-02 23:41:53 +03:00
|
|
|
CmdResult::JoinEntities(handles) => {
|
2026-08-12 12:46:41 +03:00
|
|
|
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();
|
|
|
|
|
}
|
2026-06-16 11:49:14 +03:00
|
|
|
use crate::modules::draw::modify::join::join_entities;
|
2026-05-12 10:36:15 +03:00
|
|
|
let pairs: Vec<_> = handles
|
|
|
|
|
.iter()
|
2026-04-02 23:41:53 +03:00
|
|
|
.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());
|
2026-04-02 23:41:53 +03:00
|
|
|
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(
|
fix(join): stitch touching segments into a polyline, any angle
JOIN previously only merged collinear lines or co-circular arcs into a
single Line/Arc and rejected everything else as "not co-linear" — so the
segments of a broken polyline, which meet at vertices but run at angles,
could never rejoin. It also flattened endpoints to the first segment's Z,
silently discarding height.
Rewrite the geometry pass to chain segments wherever their endpoints
touch, regardless of angle: a collinear straight run still collapses to
one Line, a planar chain becomes an LwPolyline (arcs carried as bulges),
and a chain with varying Z becomes a Polyline3D, preserving height.
Open/closed is detected from the endpoints; a selection that isn't a
single connected chain, or holds an unsupported entity / tilted arc, is
refused with a clearer message.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 21:08:23 +03:00
|
|
|
"JOIN: objects don't form a single connected chain, or contain an unsupported type / tilted arc.",
|
2026-04-02 23:41:53 +03:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-02 23:39:51 +03:00
|
|
|
CmdResult::BreakEntity { handle, p1, p2 } => {
|
2026-08-12 12:46:41 +03:00
|
|
|
if self.reject_locked_edit(i, handle) {
|
|
|
|
|
return Task::none();
|
|
|
|
|
}
|
2026-06-16 11:49:14 +03:00
|
|
|
use crate::modules::draw::modify::break_cmd::break_entity;
|
2026-05-12 10:36:15 +03:00
|
|
|
let replacement = self.tabs[i]
|
|
|
|
|
.scene
|
|
|
|
|
.document
|
2026-04-02 23:39:51 +03:00
|
|
|
.get_entity(handle)
|
fix(precision): keep typed coordinates exact in f64 (#311)
Typed values were quantized to the f32 grid before reaching the
entity (e.g. a length of 2000.8 committed as 2000.8020), worsening
with magnitude. The interaction point layer was all glam::Vec3 (f32).
Two-layer fix:
1. Coordinate backbone -> f64. last_point, last_cursor_world,
dyn_anchor, dyn_ref become DVec3; UcsXform transforms, parse_coord,
the ucs helper fns, dyn_resolve_point, the OTRACK typed-distance
paths, command-line coordinate entry and the coordinate readout all
compute in f64. Screen/GPU/alignment stays f32 (snap engine, render
geometry, projection, UCS-icon/viewcube drawing) with casts only at
those boundaries.
2. Per-command committed storage -> DVec3. on_point already received a
DVec3, but many command impls narrowed it back to f32 (let pt =
pt.as_vec3(); Vec<Vec3> fields) and re-widened at build, quantizing
the committed coordinate. Migrated PLINE, SPLINE, MLINE, LENGTHEN,
INSERT, 3D primitives, every dimension/leader/mleader, ray, revcloud,
wipeout, attdef, table, tolerance, array center, mview, plot_window,
plus the QDIM_PLACE handler and qdim_collect_points. Preview,
hit-test and rubber-band paths keep f32 at the boundary.
Hatch manual boundaries are left for a follow-up: HatchModel.boundary
is structurally Arc<Vec<[f32;2]>> and needs a model-level widen.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 21:26:40 +03:00
|
|
|
.and_then(|e| break_entity(e, p1, p2));
|
2026-04-02 23:39:51 +03:00
|
|
|
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());
|
2026-04-02 23:39:51 +03:00
|
|
|
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());
|
2026-04-02 23:39:51 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
Feat: ROADMAP Phase 1-5 batch — trim/extend, plot scale, viewport UX, mline caps, table alignment
- TRIM/EXTEND: Ray and XLine entities supported as cutting boundaries and trim targets;
trim results in Ray→Line/Ray, XLine→Ray/Ray or Line; hover preview added
- Plot scale override: Fit/1:1/1:2/1:5/1:10/1:20/1:50/1:100/2:1 buttons added to Page Setup
panel; PageSetupCommit applies scale to PlotSettings
- Viewport UX: locked viewport gets orange border; selected viewport gets white border
- Named view → viewport assignment: Choice dropdown in properties panel copies
view_target/direction/height to the viewport entity
- MLine caps: perpendicular start/end cap lines added for open (non-closed) MLines
- Wipeout polygon grips: boundary vertices become editable grips when clipping_enabled+Polygonal;
world→pixel back-projection on drag
- Table cell alignment: CellStyle.alignment (1-9) drives left/center/right + top/middle/bottom
text positioning; cxf::measure_text() helper added
- New commands/features: DIMCONTINUE (DCO), DIMBASELINE (DBA), TOLERANCE (TOL), TABLE,
ATTDEF, ATTEDIT, ATTDISP, BREAKATPOINT (BAP), PLOTWINDOW (PW), MLINE (ML);
DIMSTYLE/DIMCURRENT/CLAYER/LTSCALE/CELTSCALE/SCALETEXT/XDATA/UCSICON/REGEN/DRAWORDER extended
- PLINE arc mode: A/L/CLOSE text input, bulge computation, arc preview
- Warning cleanup: removed unused imports and unreachable patterns
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 15:31:05 +03:00
|
|
|
CmdResult::SetPlotWindow { p1, p2 } => {
|
|
|
|
|
let layout_name = self.tabs[i].scene.current_layout.clone();
|
|
|
|
|
if layout_name == "Model" {
|
2026-07-05 23:12:52 +02:00
|
|
|
// 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));
|
2026-07-24 20:49:05 +03:00
|
|
|
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());
|
feat(plot): unified plot/print dialog, retire Page Setup
Replace the old Page Setup panel with a single OCS-styled plot/print
dialog (dropdowns for printer, paper, orientation, rotation, plot area,
scale, quality, shaded plot; checkboxes for plot options). PLOT / PRINT
/ PAGESETUP all open it.
- Printer output via lp: -d printer, -n copies, -o ColorModel=Gray,
print-quality, Resolution; Windows printto verb; list_printers via
lpstat. Preview opens a temp PDF in the OS viewer (never a save
dialog). PDF export reuses the tested pipeline.
- Window pick returns to the new dialog (was the old Page Setup) and the
clipped window plot drives PDF / printer / preview.
- Remember the user's print preferences across sessions in
config/plot.txt (printer, copies, quality, output options, area,
scale); drawing-specific fields (offset/center/rotation) seed from the
layout. Default plot area Window, scale Fit.
- Delete src/ui/window/page_setup.rs and its ModalKind / Message /
buffer state; fold on_page_setup_commit into apply_plot_page_settings.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 10:27:54 +03:00
|
|
|
// 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);
|
Feat: ROADMAP Phase 1-5 batch — trim/extend, plot scale, viewport UX, mline caps, table alignment
- TRIM/EXTEND: Ray and XLine entities supported as cutting boundaries and trim targets;
trim results in Ray→Line/Ray, XLine→Ray/Ray or Line; hover preview added
- Plot scale override: Fit/1:1/1:2/1:5/1:10/1:20/1:50/1:100/2:1 buttons added to Page Setup
panel; PageSetupCommit applies scale to PlotSettings
- Viewport UX: locked viewport gets orange border; selected viewport gets white border
- Named view → viewport assignment: Choice dropdown in properties panel copies
view_target/direction/height to the viewport entity
- MLine caps: perpendicular start/end cap lines added for open (non-closed) MLines
- Wipeout polygon grips: boundary vertices become editable grips when clipping_enabled+Polygonal;
world→pixel back-projection on drag
- Table cell alignment: CellStyle.alignment (1-9) drives left/center/right + top/middle/bottom
text positioning; cxf::measure_text() helper added
- New commands/features: DIMCONTINUE (DCO), DIMBASELINE (DBA), TOLERANCE (TOL), TABLE,
ATTDEF, ATTEDIT, ATTDISP, BREAKATPOINT (BAP), PLOTWINDOW (PW), MLINE (ML);
DIMSTYLE/DIMCURRENT/CLAYER/LTSCALE/CELTSCALE/SCALETEXT/XDATA/UCSICON/REGEN/DRAWORDER extended
- PLINE arc mode: A/L/CLOSE text input, bulge computation, arc preview
- Warning cleanup: removed unused imports and unreachable patterns
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 15:31:05 +03:00
|
|
|
} else {
|
2026-07-31 11:16:14 +03:00
|
|
|
// 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!(
|
2026-07-31 11:16:14 +03:00
|
|
|
"Plot window: {x1:.2},{y1:.2} to {x2:.2},{y2:.2}"
|
2026-08-02 22:09:25 +03:00
|
|
|
).as_ref());
|
2026-07-31 11:16:14 +03:00
|
|
|
self.plot_dialog.area = "Window".to_string();
|
|
|
|
|
self.active_modal = Some(super::ModalKind::Plot);
|
Feat: ROADMAP Phase 1-5 batch — trim/extend, plot scale, viewport UX, mline caps, table alignment
- TRIM/EXTEND: Ray and XLine entities supported as cutting boundaries and trim targets;
trim results in Ray→Line/Ray, XLine→Ray/Ray or Line; hover preview added
- Plot scale override: Fit/1:1/1:2/1:5/1:10/1:20/1:50/1:100/2:1 buttons added to Page Setup
panel; PageSetupCommit applies scale to PlotSettings
- Viewport UX: locked viewport gets orange border; selected viewport gets white border
- Named view → viewport assignment: Choice dropdown in properties panel copies
view_target/direction/height to the viewport entity
- MLine caps: perpendicular start/end cap lines added for open (non-closed) MLines
- Wipeout polygon grips: boundary vertices become editable grips when clipping_enabled+Polygonal;
world→pixel back-projection on drag
- Table cell alignment: CellStyle.alignment (1-9) drives left/center/right + top/middle/bottom
text positioning; cxf::measure_text() helper added
- New commands/features: DIMCONTINUE (DCO), DIMBASELINE (DBA), TOLERANCE (TOL), TABLE,
ATTDEF, ATTEDIT, ATTDISP, BREAKATPOINT (BAP), PLOTWINDOW (PW), MLINE (ML);
DIMSTYLE/DIMCURRENT/CLAYER/LTSCALE/CELTSCALE/SCALETEXT/XDATA/UCSICON/REGEN/DRAWORDER extended
- PLINE arc mode: A/L/CLOSE text input, bulge computation, arc preview
- Warning cleanup: removed unused imports and unreachable patterns
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 15:31:05 +03:00
|
|
|
}
|
|
|
|
|
self.tabs[i].active_cmd = None;
|
|
|
|
|
self.tabs[i].snap_result = None;
|
2026-04-07 11:08:51 +03:00
|
|
|
self.tabs[i].scene.clear_preview_wire();
|
|
|
|
|
self.restore_pre_cmd_tangent();
|
|
|
|
|
}
|
2026-07-09 12:57:35 +03:00
|
|
|
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);
|
|
|
|
|
}
|
2026-08-15 15:01:05 -03:00
|
|
|
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.
|
2026-07-21 22:35:35 +03:00
|
|
|
{
|
|
|
|
|
let scene = &self.tabs[i].scene;
|
2026-08-15 15:01:05 -03:00
|
|
|
|
|
|
|
|
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(),
|
2026-07-24 20:49:05 +03:00
|
|
|
);
|
2026-07-21 22:35:35 +03:00
|
|
|
}
|
2026-08-15 15:01:05 -03:00
|
|
|
|
fix(stretch): let the selection stage behave like every other one
STRETCH ended itself when a crossing window caught nothing, so a missed
aim meant restarting the command to try again. A missed aim is not a
decision to stop: say what happened and ask for the corner afresh, the
way a selection that picks nothing leaves MOVE still asking.
Enter now ends the selection stage too. With objects already picked the
window is only there to narrow them, so there is nothing left to choose
— Enter takes a window containing all of them, which stretches every
vertex, which is the whole selection moving. That is what a
pick-selected stretch means. With nothing picked there is nothing to
end, so Enter still cancels. The prompt says so.
Not changed: with a prior selection, a window that misses every vertex
still goes on to ask for the base point. The command holds wires, not
entities, and the entity rules do not follow the wires — a small window
inside a circle catches its centre, and so the whole circle, while no
tessellated vertex is inside. Guarding on the wires would refuse that
stretch.
Closes #676
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 00:42:16 +03:00
|
|
|
use crate::command::CadCommand;
|
|
|
|
|
use crate::modules::draw::modify::stretch::StretchCommand;
|
2026-08-15 15:01:05 -03:00
|
|
|
|
|
|
|
|
let wires = self.tabs[i].scene.wire_models_for(&handles);
|
|
|
|
|
|
|
|
|
|
let cmd = StretchCommand::with_windows(
|
|
|
|
|
handles,
|
|
|
|
|
wires,
|
|
|
|
|
windows,
|
|
|
|
|
);
|
|
|
|
|
|
fix(stretch): let the selection stage behave like every other one
STRETCH ended itself when a crossing window caught nothing, so a missed
aim meant restarting the command to try again. A missed aim is not a
decision to stop: say what happened and ask for the corner afresh, the
way a selection that picks nothing leaves MOVE still asking.
Enter now ends the selection stage too. With objects already picked the
window is only there to narrow them, so there is nothing left to choose
— Enter takes a window containing all of them, which stretches every
vertex, which is the whole selection moving. That is what a
pick-selected stretch means. With nothing picked there is nothing to
end, so Enter still cancels. The prompt says so.
Not changed: with a prior selection, a window that misses every vertex
still goes on to ask for the base point. The command holds wires, not
entities, and the entity rules do not follow the wires — a small window
inside a circle catches its centre, and so the whole circle, while no
tessellated vertex is inside. Guarding on the wires would refuse that
stretch.
Closes #676
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 00:42:16 +03:00
|
|
|
self.command_line.push_info(&CadCommand::prompt(&cmd));
|
|
|
|
|
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
2026-07-21 22:35:35 +03:00
|
|
|
}
|
2026-05-12 10:36:15 +03:00
|
|
|
CmdResult::StretchEntities {
|
2026-08-12 12:46:41 +03:00
|
|
|
mut handles,
|
2026-08-15 15:01:05 -03:00
|
|
|
windows,
|
2026-05-12 10:36:15 +03:00
|
|
|
delta,
|
|
|
|
|
} => {
|
2026-08-12 12:46:41 +03:00
|
|
|
handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle));
|
|
|
|
|
if handles.is_empty() {
|
|
|
|
|
self.tabs[i].active_cmd = None;
|
|
|
|
|
return Task::none();
|
|
|
|
|
}
|
2026-07-24 23:25:33 +03:00
|
|
|
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);
|
2026-04-07 11:25:39 +03:00
|
|
|
let mut count = 0usize;
|
2026-07-24 23:25:33 +03:00
|
|
|
let mut changed_handles = Vec::new();
|
2026-04-07 11:25:39 +03:00
|
|
|
|
|
|
|
|
// Helper: is DXF point (x, y) inside the world-space window?
|
fix(commands): finish the world-XY coordinate migration (#54)
The drawing plane is world XY, but break, join, rectangular array, the
stretch/window move, the spline preview wire and several doc comments
still used the stale "Y-up XZ" mapping (world Z = DXF Y). That made
those commands operate against a constant-elevation axis: arc/circle/
line/spline break and join picked the wrong points, rectangular array
stepped rows along the Z (elevation) axis, window-stretch tested the
wrong window bound, and the spline wire drew in the wrong plane.
Map every drawing-plane coordinate through .y (elevation on .z),
matching the entity builders, the inquiry commands and the central
transform. Stale Y-up comments updated to avoid future confusion.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 10:39:24 +03:00
|
|
|
// Drawing plane is world XY (= DXF XY).
|
2026-04-07 11:25:39 +03:00
|
|
|
let in_win = |x: f64, y: f64| -> bool {
|
2026-08-15 15:01:05 -03:00
|
|
|
windows.iter().any(|(win_min, win_max)| {
|
|
|
|
|
x >= win_min.x
|
|
|
|
|
&& x <= win_max.x
|
|
|
|
|
&& y >= win_min.y
|
|
|
|
|
&& y <= win_max.y
|
|
|
|
|
})
|
2026-04-07 11:25:39 +03:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let dx = delta.x as f64;
|
fix(commands): finish the world-XY coordinate migration (#54)
The drawing plane is world XY, but break, join, rectangular array, the
stretch/window move, the spline preview wire and several doc comments
still used the stale "Y-up XZ" mapping (world Z = DXF Y). That made
those commands operate against a constant-elevation axis: arc/circle/
line/spline break and join picked the wrong points, rectangular array
stepped rows along the Z (elevation) axis, window-stretch tested the
wrong window bound, and the spline wire drew in the wrong plane.
Map every drawing-plane coordinate through .y (elevation on .z),
matching the entity builders, the inquiry commands and the central
transform. Stale Y-up comments updated to avoid future confusion.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 10:39:24 +03:00
|
|
|
let dy = delta.y as f64; // drawing plane is world XY
|
|
|
|
|
let dz = delta.z as f64;
|
2026-04-07 11:25:39 +03:00
|
|
|
|
2026-07-21 22:24:20 +03:00
|
|
|
// 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();
|
2026-04-07 11:25:39 +03:00
|
|
|
for handle in &handles {
|
2026-07-24 23:25:33 +03:00
|
|
|
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;
|
|
|
|
|
};
|
2026-04-07 11:25:39 +03:00
|
|
|
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;
|
|
|
|
|
}
|
2026-04-07 11:25:39 +03:00
|
|
|
}
|
|
|
|
|
acadrust::EntityType::LwPolyline(p) => {
|
|
|
|
|
for v in &mut p.vertices {
|
|
|
|
|
if in_win(v.location.x, v.location.y) {
|
|
|
|
|
v.location.x += dx;
|
|
|
|
|
v.location.y += dy;
|
|
|
|
|
stretched = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
acadrust::EntityType::Polyline2D(p) => {
|
|
|
|
|
for v in &mut p.vertices {
|
|
|
|
|
if in_win(v.location.x, v.location.y) {
|
|
|
|
|
v.location.x += dx;
|
|
|
|
|
v.location.y += dy;
|
|
|
|
|
stretched = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
acadrust::EntityType::Polyline(p) => {
|
|
|
|
|
for v in &mut p.vertices {
|
|
|
|
|
if in_win(v.location.x, v.location.z) {
|
|
|
|
|
v.location.x += dx;
|
|
|
|
|
v.location.z += dy;
|
|
|
|
|
stretched = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
acadrust::EntityType::Arc(a) => {
|
|
|
|
|
if in_win(a.center.x, a.center.y) {
|
2026-05-12 10:36:15 +03:00
|
|
|
a.center.x += dx;
|
|
|
|
|
a.center.y += dy;
|
|
|
|
|
a.center.z += dz;
|
2026-04-07 11:25:39 +03:00
|
|
|
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;
|
2026-04-07 11:25:39 +03:00
|
|
|
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;
|
2026-04-07 11:25:39 +03:00
|
|
|
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;
|
2026-04-07 11:25:39 +03:00
|
|
|
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;
|
2026-04-07 11:25:39 +03:00
|
|
|
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;
|
2026-04-07 11:25:39 +03:00
|
|
|
stretched = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-24 19:26:10 +03:00
|
|
|
acadrust::EntityType::Viewport(vp) => {
|
2026-08-15 15:01:05 -03:00
|
|
|
stretched = windows.iter().any(|(win_min, win_max)| {
|
|
|
|
|
crate::entities::viewport::stretch(
|
|
|
|
|
vp,
|
|
|
|
|
*win_min,
|
|
|
|
|
*win_max,
|
|
|
|
|
delta,
|
|
|
|
|
)
|
|
|
|
|
});
|
2026-07-24 19:26:10 +03:00
|
|
|
}
|
2026-07-21 22:24:20 +03:00
|
|
|
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.
|
2026-07-21 23:41:27 +03:00
|
|
|
let mv = |p: &mut acadrust::types::Vector3| {
|
2026-07-21 22:24:20 +03:00
|
|
|
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);
|
|
|
|
|
}
|
2026-07-26 19:30:43 +03:00
|
|
|
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);
|
|
|
|
|
}
|
2026-07-21 22:24:20 +03:00
|
|
|
}
|
|
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-07 11:25:39 +03:00
|
|
|
_ => {
|
|
|
|
|
// Generic: move entire entity (treat as block-level)
|
|
|
|
|
stretched = false; // skip generic types
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-12 10:36:15 +03:00
|
|
|
if stretched {
|
2026-07-24 23:25:33 +03:00
|
|
|
if let Some(before) = before {
|
|
|
|
|
self.tabs[i].scene.record_undo_before(*handle, Some(before));
|
|
|
|
|
}
|
2026-06-14 00:36:17 +03:00
|
|
|
self.tabs[i].scene.mark_entity_dirty(*handle);
|
2026-07-24 23:25:33 +03:00
|
|
|
changed_handles.push(*handle);
|
2026-05-12 10:36:15 +03:00
|
|
|
count += 1;
|
|
|
|
|
}
|
2026-04-07 11:25:39 +03:00
|
|
|
}
|
2026-07-21 22:24:20 +03:00
|
|
|
// 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 {
|
2026-07-24 23:25:33 +03:00
|
|
|
self.tabs[i].scene.invalidate_dim_block_recorded(h);
|
2026-07-21 22:24:20 +03:00
|
|
|
}
|
2026-04-07 11:25:39 +03:00
|
|
|
|
2026-06-14 00:36:17 +03:00
|
|
|
// 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 {
|
2026-07-28 17:16:27 +03:00
|
|
|
let changes: Vec<_> = changed_handles
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|&handle| (handle, crate::scene::ChangeKind::Modified))
|
|
|
|
|
.collect();
|
|
|
|
|
self.tabs[i].scene.bump_entities(&changes);
|
2026-06-14 00:36:17 +03:00
|
|
|
}
|
2026-04-07 11:25:39 +03:00
|
|
|
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());
|
2026-04-07 11:25:39 +03:00
|
|
|
self.refresh_properties();
|
2026-07-24 23:25:33 +03:00
|
|
|
if let Some(pending) = pending {
|
|
|
|
|
self.commit_undo_delta(i, pending);
|
|
|
|
|
}
|
2026-04-07 11:25:39 +03:00
|
|
|
}
|
feat: BOX, SPHERE, CYLINDER primitives + EXTRUDE / REVOLVE commands
BOX, SPHERE, and CYLINDER build truck topology solids and tessellate
them into MeshModels stored alongside a Solid3D placeholder entity so
the GPU mesh pipeline renders them immediately.
EXTRUDE picks any closed 2D profile (Circle, LwPolyline, etc.),
attaches a planar face via try_attach_plane, and translational-sweeps
it (tsweep Face → Solid) along Z by the given height.
REVOLVE picks a profile, two axis points, and an angle (default 360°),
then rotational-sweeps the wire (rsweep) around the axis.
Three new CmdResult variants drive the operations:
CommitSolid3D, ExtrudeEntity, RevolveEntity.
Scene::layer_color() added to retrieve the active layer's RGBA color.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:55:28 +03:00
|
|
|
// ── Solid3D creation (BOX / SPHERE / CYLINDER) ────────────────
|
|
|
|
|
CmdResult::CommitSolid3D { mesh_fn } => {
|
|
|
|
|
use crate::modules::insert::solid3d_cmds::empty_solid3d;
|
2026-07-25 01:06:00 +03:00
|
|
|
let pending = self.begin_undo(i, "SOLID3D", 1, true);
|
feat: BOX, SPHERE, CYLINDER primitives + EXTRUDE / REVOLVE commands
BOX, SPHERE, and CYLINDER build truck topology solids and tessellate
them into MeshModels stored alongside a Solid3D placeholder entity so
the GPU mesh pipeline renders them immediately.
EXTRUDE picks any closed 2D profile (Circle, LwPolyline, etc.),
attaches a planar face via try_attach_plane, and translational-sweeps
it (tsweep Face → Solid) along Z by the given height.
REVOLVE picks a profile, two axis points, and an angle (default 360°),
then rotational-sweeps the wire (rsweep) around the axis.
Three new CmdResult variants drive the operations:
CommitSolid3D, ExtrudeEntity, RevolveEntity.
Scene::layer_color() added to retrieve the active layer's RGBA color.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:55:28 +03:00
|
|
|
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) {
|
2026-08-11 19:39:01 +03:00
|
|
|
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);
|
feat: BOX, SPHERE, CYLINDER primitives + EXTRUDE / REVOLVE commands
BOX, SPHERE, and CYLINDER build truck topology solids and tessellate
them into MeshModels stored alongside a Solid3D placeholder entity so
the GPU mesh pipeline renders them immediately.
EXTRUDE picks any closed 2D profile (Circle, LwPolyline, etc.),
attaches a planar face via try_attach_plane, and translational-sweeps
it (tsweep Face → Solid) along Z by the given height.
REVOLVE picks a profile, two axis points, and an angle (default 360°),
then rotational-sweeps the wire (rsweep) around the axis.
Three new CmdResult variants drive the operations:
CommitSolid3D, ExtrudeEntity, RevolveEntity.
Scene::layer_color() added to retrieve the active layer's RGBA color.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:55:28 +03:00
|
|
|
}
|
|
|
|
|
self.tabs[i].dirty = true;
|
2026-08-02 22:09:25 +03:00
|
|
|
self.command_line.push_output(crate::t!("Solid created.").as_ref());
|
feat: BOX, SPHERE, CYLINDER primitives + EXTRUDE / REVOLVE commands
BOX, SPHERE, and CYLINDER build truck topology solids and tessellate
them into MeshModels stored alongside a Solid3D placeholder entity so
the GPU mesh pipeline renders them immediately.
EXTRUDE picks any closed 2D profile (Circle, LwPolyline, etc.),
attaches a planar face via try_attach_plane, and translational-sweeps
it (tsweep Face → Solid) along Z by the given height.
REVOLVE picks a profile, two axis points, and an angle (default 360°),
then rotational-sweeps the wire (rsweep) around the axis.
Three new CmdResult variants drive the operations:
CommitSolid3D, ExtrudeEntity, RevolveEntity.
Scene::layer_color() added to retrieve the active layer's RGBA color.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:55:28 +03:00
|
|
|
}
|
2026-07-25 01:06:00 +03:00
|
|
|
if let Some(pd) = pending {
|
|
|
|
|
self.commit_undo_delta(i, pd);
|
|
|
|
|
}
|
feat: BOX, SPHERE, CYLINDER primitives + EXTRUDE / REVOLVE commands
BOX, SPHERE, and CYLINDER build truck topology solids and tessellate
them into MeshModels stored alongside a Solid3D placeholder entity so
the GPU mesh pipeline renders them immediately.
EXTRUDE picks any closed 2D profile (Circle, LwPolyline, etc.),
attaches a planar face via try_attach_plane, and translational-sweeps
it (tsweep Face → Solid) along Z by the given height.
REVOLVE picks a profile, two axis points, and an angle (default 360°),
then rotational-sweeps the wire (rsweep) around the axis.
Three new CmdResult variants drive the operations:
CommitSolid3D, ExtrudeEntity, RevolveEntity.
Scene::layer_color() added to retrieve the active layer's RGBA color.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:55:28 +03:00
|
|
|
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,
|
|
|
|
|
} => {
|
2026-08-12 12:46:41 +03:00
|
|
|
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};
|
feat: BOX, SPHERE, CYLINDER primitives + EXTRUDE / REVOLVE commands
BOX, SPHERE, and CYLINDER build truck topology solids and tessellate
them into MeshModels stored alongside a Solid3D placeholder entity so
the GPU mesh pipeline renders them immediately.
EXTRUDE picks any closed 2D profile (Circle, LwPolyline, etc.),
attaches a planar face via try_attach_plane, and translational-sweeps
it (tsweep Face → Solid) along Z by the given height.
REVOLVE picks a profile, two axis points, and an angle (default 360°),
then rotational-sweeps the wire (rsweep) around the axis.
Three new CmdResult variants drive the operations:
CommitSolid3D, ExtrudeEntity, RevolveEntity.
Scene::layer_color() added to retrieve the active layer's RGBA color.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:55:28 +03:00
|
|
|
|
|
|
|
|
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 {
|
2026-08-11 19:52:17 +03:00
|
|
|
let history = crate::scene::model::solid_history::extrusion_op(
|
|
|
|
|
&entity,
|
|
|
|
|
height as f64,
|
|
|
|
|
);
|
2026-07-25 01:06:00 +03:00
|
|
|
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);
|
2026-08-11 19:52:17 +03:00
|
|
|
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;
|
feat: BOX, SPHERE, CYLINDER primitives + EXTRUDE / REVOLVE commands
BOX, SPHERE, and CYLINDER build truck topology solids and tessellate
them into MeshModels stored alongside a Solid3D placeholder entity so
the GPU mesh pipeline renders them immediately.
EXTRUDE picks any closed 2D profile (Circle, LwPolyline, etc.),
attaches a planar face via try_attach_plane, and translational-sweeps
it (tsweep Face → Solid) along Z by the given height.
REVOLVE picks a profile, two axis points, and an angle (default 360°),
then rotational-sweeps the wire (rsweep) around the axis.
Three new CmdResult variants drive the operations:
CommitSolid3D, ExtrudeEntity, RevolveEntity.
Scene::layer_color() added to retrieve the active layer's RGBA color.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:55:28 +03:00
|
|
|
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());
|
2026-07-25 01:06:00 +03:00
|
|
|
if let Some(pd) = pending {
|
|
|
|
|
self.commit_undo_delta(i, pd);
|
|
|
|
|
}
|
feat: BOX, SPHERE, CYLINDER primitives + EXTRUDE / REVOLVE commands
BOX, SPHERE, and CYLINDER build truck topology solids and tessellate
them into MeshModels stored alongside a Solid3D placeholder entity so
the GPU mesh pipeline renders them immediately.
EXTRUDE picks any closed 2D profile (Circle, LwPolyline, etc.),
attaches a planar face via try_attach_plane, and translational-sweeps
it (tsweep Face → Solid) along Z by the given height.
REVOLVE picks a profile, two axis points, and an angle (default 360°),
then rotational-sweeps the wire (rsweep) around the axis.
Three new CmdResult variants drive the operations:
CommitSolid3D, ExtrudeEntity, RevolveEntity.
Scene::layer_color() added to retrieve the active layer's RGBA color.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:55:28 +03:00
|
|
|
} 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());
|
feat: BOX, SPHERE, CYLINDER primitives + EXTRUDE / REVOLVE commands
BOX, SPHERE, and CYLINDER build truck topology solids and tessellate
them into MeshModels stored alongside a Solid3D placeholder entity so
the GPU mesh pipeline renders them immediately.
EXTRUDE picks any closed 2D profile (Circle, LwPolyline, etc.),
attaches a planar face via try_attach_plane, and translational-sweeps
it (tsweep Face → Solid) along Z by the given height.
REVOLVE picks a profile, two axis points, and an angle (default 360°),
then rotational-sweeps the wire (rsweep) around the axis.
Three new CmdResult variants drive the operations:
CommitSolid3D, ExtrudeEntity, RevolveEntity.
Scene::layer_color() added to retrieve the active layer's RGBA color.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:55:28 +03:00
|
|
|
}
|
|
|
|
|
} else {
|
2026-08-02 22:09:25 +03:00
|
|
|
self.command_line.push_error(crate::t!("EXTRUDE: entity not found.").as_ref());
|
feat: BOX, SPHERE, CYLINDER primitives + EXTRUDE / REVOLVE commands
BOX, SPHERE, and CYLINDER build truck topology solids and tessellate
them into MeshModels stored alongside a Solid3D placeholder entity so
the GPU mesh pipeline renders them immediately.
EXTRUDE picks any closed 2D profile (Circle, LwPolyline, etc.),
attaches a planar face via try_attach_plane, and translational-sweeps
it (tsweep Face → Solid) along Z by the given height.
REVOLVE picks a profile, two axis points, and an angle (default 360°),
then rotational-sweeps the wire (rsweep) around the axis.
Three new CmdResult variants drive the operations:
CommitSolid3D, ExtrudeEntity, RevolveEntity.
Scene::layer_color() added to retrieve the active layer's RGBA color.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:55:28 +03:00
|
|
|
}
|
|
|
|
|
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,
|
|
|
|
|
} => {
|
2026-08-12 12:46:41 +03:00
|
|
|
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};
|
feat: BOX, SPHERE, CYLINDER primitives + EXTRUDE / REVOLVE commands
BOX, SPHERE, and CYLINDER build truck topology solids and tessellate
them into MeshModels stored alongside a Solid3D placeholder entity so
the GPU mesh pipeline renders them immediately.
EXTRUDE picks any closed 2D profile (Circle, LwPolyline, etc.),
attaches a planar face via try_attach_plane, and translational-sweeps
it (tsweep Face → Solid) along Z by the given height.
REVOLVE picks a profile, two axis points, and an angle (default 360°),
then rotational-sweeps the wire (rsweep) around the axis.
Three new CmdResult variants drive the operations:
CommitSolid3D, ExtrudeEntity, RevolveEntity.
Scene::layer_color() added to retrieve the active layer's RGBA color.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:55:28 +03:00
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
[
|
feat: BOX, SPHERE, CYLINDER primitives + EXTRUDE / REVOLVE commands
BOX, SPHERE, and CYLINDER build truck topology solids and tessellate
them into MeshModels stored alongside a Solid3D placeholder entity so
the GPU mesh pipeline renders them immediately.
EXTRUDE picks any closed 2D profile (Circle, LwPolyline, etc.),
attaches a planar face via try_attach_plane, and translational-sweeps
it (tsweep Face → Solid) along Z by the given height.
REVOLVE picks a profile, two axis points, and an angle (default 360°),
then rotational-sweeps the wire (rsweep) around the axis.
Three new CmdResult variants drive the operations:
CommitSolid3D, ExtrudeEntity, RevolveEntity.
Scene::layer_color() added to retrieve the active layer's RGBA color.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:55:28 +03:00
|
|
|
axis_start.x as f64,
|
|
|
|
|
axis_start.y as f64,
|
fix(3d): revolve axis and OBJ import use the Z-up world, not stale Y-up
The truck B-rep pipeline is Z-up (solids extrude/raise along Z, profile
wires convert pass-through), but two spots still assumed a Y-up world:
- REVOLVE swapped the axis/origin to (x, z, y), so the revolve axis sat
in the XZ plane while the profile sat in XY → wrong solid. Use the
axis/origin straight through.
- OBJ import kept Y-up coordinates as-is on the (now Z-up) viewport, so
imported meshes lay on their side. Rotate +90° about X on import
(positions + normals): (x, y, z) → (x, -z, y).
EXTRUDE (sweep along +Z) and the box/cylinder primitives were already
Z-up and unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 11:03:03 +03:00
|
|
|
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 {
|
2026-08-11 19:52:17 +03:00
|
|
|
let history = crate::scene::model::solid_history::revolve_op(
|
|
|
|
|
&entity,
|
|
|
|
|
axis_start.to_array(),
|
|
|
|
|
axis_end.to_array(),
|
|
|
|
|
(angle_deg as f64).to_radians(),
|
|
|
|
|
);
|
2026-07-25 01:06:00 +03:00
|
|
|
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);
|
2026-08-11 19:52:17 +03:00
|
|
|
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;
|
feat: BOX, SPHERE, CYLINDER primitives + EXTRUDE / REVOLVE commands
BOX, SPHERE, and CYLINDER build truck topology solids and tessellate
them into MeshModels stored alongside a Solid3D placeholder entity so
the GPU mesh pipeline renders them immediately.
EXTRUDE picks any closed 2D profile (Circle, LwPolyline, etc.),
attaches a planar face via try_attach_plane, and translational-sweeps
it (tsweep Face → Solid) along Z by the given height.
REVOLVE picks a profile, two axis points, and an angle (default 360°),
then rotational-sweeps the wire (rsweep) around the axis.
Three new CmdResult variants drive the operations:
CommitSolid3D, ExtrudeEntity, RevolveEntity.
Scene::layer_color() added to retrieve the active layer's RGBA color.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:55:28 +03:00
|
|
|
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());
|
2026-07-25 01:06:00 +03:00
|
|
|
if let Some(pd) = pending {
|
|
|
|
|
self.commit_undo_delta(i, pd);
|
|
|
|
|
}
|
feat: BOX, SPHERE, CYLINDER primitives + EXTRUDE / REVOLVE commands
BOX, SPHERE, and CYLINDER build truck topology solids and tessellate
them into MeshModels stored alongside a Solid3D placeholder entity so
the GPU mesh pipeline renders them immediately.
EXTRUDE picks any closed 2D profile (Circle, LwPolyline, etc.),
attaches a planar face via try_attach_plane, and translational-sweeps
it (tsweep Face → Solid) along Z by the given height.
REVOLVE picks a profile, two axis points, and an angle (default 360°),
then rotational-sweeps the wire (rsweep) around the axis.
Three new CmdResult variants drive the operations:
CommitSolid3D, ExtrudeEntity, RevolveEntity.
Scene::layer_color() added to retrieve the active layer's RGBA color.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:55:28 +03:00
|
|
|
} 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());
|
feat: BOX, SPHERE, CYLINDER primitives + EXTRUDE / REVOLVE commands
BOX, SPHERE, and CYLINDER build truck topology solids and tessellate
them into MeshModels stored alongside a Solid3D placeholder entity so
the GPU mesh pipeline renders them immediately.
EXTRUDE picks any closed 2D profile (Circle, LwPolyline, etc.),
attaches a planar face via try_attach_plane, and translational-sweeps
it (tsweep Face → Solid) along Z by the given height.
REVOLVE picks a profile, two axis points, and an angle (default 360°),
then rotational-sweeps the wire (rsweep) around the axis.
Three new CmdResult variants drive the operations:
CommitSolid3D, ExtrudeEntity, RevolveEntity.
Scene::layer_color() added to retrieve the active layer's RGBA color.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:55:28 +03:00
|
|
|
}
|
|
|
|
|
} 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());
|
feat: BOX, SPHERE, CYLINDER primitives + EXTRUDE / REVOLVE commands
BOX, SPHERE, and CYLINDER build truck topology solids and tessellate
them into MeshModels stored alongside a Solid3D placeholder entity so
the GPU mesh pipeline renders them immediately.
EXTRUDE picks any closed 2D profile (Circle, LwPolyline, etc.),
attaches a planar face via try_attach_plane, and translational-sweeps
it (tsweep Face → Solid) along Z by the given height.
REVOLVE picks a profile, two axis points, and an angle (default 360°),
then rotational-sweeps the wire (rsweep) around the axis.
Three new CmdResult variants drive the operations:
CommitSolid3D, ExtrudeEntity, RevolveEntity.
Scene::layer_color() added to retrieve the active layer's RGBA color.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:55:28 +03:00
|
|
|
}
|
|
|
|
|
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,
|
|
|
|
|
} => {
|
2026-08-12 12:46:41 +03:00
|
|
|
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-04-08 18:07:00 +03:00
|
|
|
|
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();
|
2026-08-11 19:52:17 +03:00
|
|
|
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));
|
2026-04-08 18:07:00 +03:00
|
|
|
|
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 {
|
2026-07-25 01:06:00 +03:00
|
|
|
let pending = self.begin_undo(i, "SWEEP", 1, true);
|
2026-08-11 19:39:01 +03:00
|
|
|
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);
|
2026-08-11 19:52:17 +03:00
|
|
|
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);
|
2026-04-08 18:07:00 +03:00
|
|
|
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());
|
2026-07-25 01:06:00 +03:00
|
|
|
if let Some(pd) = pending {
|
|
|
|
|
self.commit_undo_delta(i, pd);
|
|
|
|
|
}
|
2026-04-08 18:07:00 +03:00
|
|
|
} 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());
|
2026-04-08 18:07:00 +03:00
|
|
|
}
|
|
|
|
|
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 ──────────────────────────────────────────────────────
|
2026-04-08 18:08:53 +03:00
|
|
|
CmdResult::LoftEntities { handles, color } => {
|
2026-08-12 12:46:41 +03:00
|
|
|
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;
|
2026-04-08 18:08:53 +03:00
|
|
|
|
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) {
|
2026-08-11 19:52:17 +03:00
|
|
|
let history = crate::scene::model::solid_history::loft_op(&profiles);
|
2026-07-25 01:06:00 +03:00
|
|
|
let pending = self.begin_undo(i, "LOFT", 1, true);
|
2026-08-11 19:39:01 +03:00
|
|
|
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);
|
2026-08-11 19:52:17 +03:00
|
|
|
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);
|
2026-04-08 18:08:53 +03:00
|
|
|
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());
|
2026-07-25 01:06:00 +03:00
|
|
|
if let Some(pd) = pending {
|
|
|
|
|
self.commit_undo_delta(i, pd);
|
|
|
|
|
}
|
2026-04-08 18:08:53 +03:00
|
|
|
} 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());
|
2026-04-08 18:08:53 +03:00
|
|
|
}
|
|
|
|
|
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,
|
|
|
|
|
} => {
|
2026-08-12 12:46:41 +03:00
|
|
|
if self.reject_locked_edit(i, handle) {
|
|
|
|
|
return Task::none();
|
|
|
|
|
}
|
2026-04-07 11:08:51 +03:00
|
|
|
if let Some(mut model) = self.tabs[i].scene.hatches.get(&handle).cloned() {
|
2026-08-12 00:23:21 -03:00
|
|
|
let layer = self.tabs[i]
|
|
|
|
|
.scene
|
|
|
|
|
.document
|
|
|
|
|
.get_entity(handle)
|
|
|
|
|
.map(|entity| entity.as_entity().layer().to_string())
|
|
|
|
|
.unwrap_or_else(|| "0".to_string());
|
2026-04-07 11:08:51 +03:00
|
|
|
// Update model fields
|
|
|
|
|
if !name.is_empty() {
|
2026-06-16 11:33:46 +03:00
|
|
|
use crate::scene::model::hatch_model::HatchPattern;
|
|
|
|
|
use crate::scene::model::hatch_patterns;
|
2026-04-07 11:08:51 +03:00
|
|
|
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
|
2026-08-13 23:47:50 +03:00
|
|
|
self.tabs[i].scene.add_hatch(model, Some(&layer), None);
|
2026-04-07 11:08:51 +03:00
|
|
|
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());
|
2026-04-07 11:08:51 +03:00
|
|
|
} 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());
|
2026-04-07 11:08:51 +03:00
|
|
|
}
|
|
|
|
|
self.tabs[i].active_cmd = None;
|
|
|
|
|
self.tabs[i].snap_result = None;
|
Feat: ROADMAP Phase 1-5 batch — trim/extend, plot scale, viewport UX, mline caps, table alignment
- TRIM/EXTEND: Ray and XLine entities supported as cutting boundaries and trim targets;
trim results in Ray→Line/Ray, XLine→Ray/Ray or Line; hover preview added
- Plot scale override: Fit/1:1/1:2/1:5/1:10/1:20/1:50/1:100/2:1 buttons added to Page Setup
panel; PageSetupCommit applies scale to PlotSettings
- Viewport UX: locked viewport gets orange border; selected viewport gets white border
- Named view → viewport assignment: Choice dropdown in properties panel copies
view_target/direction/height to the viewport entity
- MLine caps: perpendicular start/end cap lines added for open (non-closed) MLines
- Wipeout polygon grips: boundary vertices become editable grips when clipping_enabled+Polygonal;
world→pixel back-projection on drag
- Table cell alignment: CellStyle.alignment (1-9) drives left/center/right + top/middle/bottom
text positioning; cxf::measure_text() helper added
- New commands/features: DIMCONTINUE (DCO), DIMBASELINE (DBA), TOLERANCE (TOL), TABLE,
ATTDEF, ATTEDIT, ATTDISP, BREAKATPOINT (BAP), PLOTWINDOW (PW), MLINE (ML);
DIMSTYLE/DIMCURRENT/CLAYER/LTSCALE/CELTSCALE/SCALETEXT/XDATA/UCSICON/REGEN/DRAWORDER extended
- PLINE arc mode: A/L/CLOSE text input, bulge computation, arc preview
- Warning cleanup: removed unused imports and unreachable patterns
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 15:31:05 +03:00
|
|
|
self.tabs[i].scene.clear_preview_wire();
|
|
|
|
|
self.restore_pre_cmd_tangent();
|
|
|
|
|
}
|
feat: Implement in-place MText editor with formatting toolbar and live preview
- Added SVG icons for text formatting options (bold, italic, underline, overline, strike, uppercase, lowercase).
- Introduced a new `MTextEditorState` struct to manage the state of the MText editor.
- Implemented functionality to open the MText editor from the command line and edit existing MText entities.
- Added message handling for various MText editor actions (formatting, height, style, font, justification, alignment, line spacing).
- Created a dedicated preview area for MText rendering within the editor.
- Updated the command structure to support opening the MText editor and handling text input.
- Refactored existing MText command logic to integrate with the new editor.
2026-05-30 23:24:06 +03:00
|
|
|
CmdResult::OpenMTextEditor {
|
|
|
|
|
pos,
|
|
|
|
|
handle,
|
|
|
|
|
initial,
|
|
|
|
|
height,
|
|
|
|
|
} => {
|
|
|
|
|
self.tabs[i].active_cmd = None;
|
|
|
|
|
self.tabs[i].snap_result = None;
|
2026-07-12 00:34:26 +03:00
|
|
|
self.open_mtext_editor(pos, handle, &initial, height);
|
feat: Implement in-place MText editor with formatting toolbar and live preview
- Added SVG icons for text formatting options (bold, italic, underline, overline, strike, uppercase, lowercase).
- Introduced a new `MTextEditorState` struct to manage the state of the MText editor.
- Implemented functionality to open the MText editor from the command line and edit existing MText entities.
- Added message handling for various MText editor actions (formatting, height, style, font, justification, alignment, line spacing).
- Created a dedicated preview area for MText rendering within the editor.
- Updated the command structure to support opening the MText editor and handling text input.
- Refactored existing MText command logic to integrate with the new editor.
2026-05-30 23:24:06 +03:00
|
|
|
}
|
2026-05-31 14:44:18 +03:00
|
|
|
CmdResult::OpenTextEditor {
|
|
|
|
|
pos,
|
|
|
|
|
handle,
|
|
|
|
|
initial,
|
|
|
|
|
height,
|
|
|
|
|
} => {
|
|
|
|
|
self.tabs[i].active_cmd = None;
|
|
|
|
|
self.tabs[i].snap_result = None;
|
|
|
|
|
self.open_text_inline(
|
2026-07-12 00:34:26 +03:00
|
|
|
pos,
|
2026-05-31 14:44:18 +03:00
|
|
|
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);
|
|
|
|
|
}
|
2026-06-18 14:55:08 +01:00
|
|
|
CmdResult::SuspendForTextEdit { handle } => {
|
2026-07-24 20:49:05 +03:00
|
|
|
let is_editable =
|
|
|
|
|
crate::app::text_inline::can_edit_text(handle, &self.tabs[i].scene.document);
|
2026-06-18 14:55:08 +01:00
|
|
|
if !is_editable {
|
2026-07-24 20:49:05 +03:00
|
|
|
self.command_line
|
2026-08-02 22:09:25 +03:00
|
|
|
.push_error(crate::t!("TEXTEDIT: selected entity is not text.").as_ref());
|
2026-06-18 14:55:08 +01: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);
|
|
|
|
|
}
|
|
|
|
|
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 };
|
2026-07-24 20:49:05 +03:00
|
|
|
self.command_line
|
2026-08-02 22:09:25 +03:00
|
|
|
.push_output(crate::tf!("TEXTEDITMODE set to {display_val}").as_ref());
|
2026-06-18 14:55:08 +01:00
|
|
|
self.tabs[i].active_cmd = None;
|
|
|
|
|
self.tabs[i].snap_result = None;
|
|
|
|
|
self.tabs[i].scene.clear_preview_wire();
|
|
|
|
|
}
|
2026-04-07 11:05:24 +03:00
|
|
|
CmdResult::DdeditEntity { handle, new_text } => {
|
2026-08-12 12:46:41 +03:00
|
|
|
if self.reject_locked_edit(i, handle) {
|
|
|
|
|
self.tabs[i].active_cmd = None;
|
|
|
|
|
return Task::none();
|
|
|
|
|
}
|
2026-07-24 23:25:33 +03:00
|
|
|
self.push_undo_snapshot(i, "DDEDIT");
|
2026-04-07 11:05:24 +03:00
|
|
|
let mut updated = false;
|
2026-07-08 10:23:48 +03:00
|
|
|
let mut is_dim = false;
|
2026-04-07 11:05:24 +03:00
|
|
|
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;
|
|
|
|
|
}
|
feat: add missing Annotate tab commands and icons
New dimension commands: QDIM, DIMEDIT, DIMTEDIT, DIMBREAK, DIMSPACE,
DIMJOGLINE with full command workflows and SVG icons.
New multileader commands: MLEADERADD, MLEADERREMOVE, MLEADERALIGN,
MLEADERCOLLECT with entity injection pattern for in-place editing.
Updated annotate ribbon: all tools exposed across Text, Dimensions,
Leaders, Tables, Markup, and Annotation Scaling groups. Added STYLE,
FIND, DIMSTYLE shortcut buttons.
Added `inject_picked_entity` to the CadCommand trait for commands that
need a cloned copy of the picked entity to modify (DIMTEDIT, MLEADERADD,
MLEADERREMOVE). Sentinel-based dispatch in cmd_result.rs handles
DIMBREAK, DIMSPACE, DIMJOGLINE, MLEADERALIGN, and MLEADERCOLLECT.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 03:26:47 +03:00
|
|
|
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;
|
2026-07-08 10:23:48 +03:00
|
|
|
is_dim = true;
|
feat: add missing Annotate tab commands and icons
New dimension commands: QDIM, DIMEDIT, DIMTEDIT, DIMBREAK, DIMSPACE,
DIMJOGLINE with full command workflows and SVG icons.
New multileader commands: MLEADERADD, MLEADERREMOVE, MLEADERALIGN,
MLEADERCOLLECT with entity injection pattern for in-place editing.
Updated annotate ribbon: all tools exposed across Text, Dimensions,
Leaders, Tables, Markup, and Annotation Scaling groups. Added STYLE,
FIND, DIMSTYLE shortcut buttons.
Added `inject_picked_entity` to the CadCommand trait for commands that
need a cloned copy of the picked entity to modify (DIMTEDIT, MLEADERADD,
MLEADERREMOVE). Sentinel-based dispatch in cmd_result.rs handles
DIMBREAK, DIMSPACE, DIMJOGLINE, MLEADERALIGN, and MLEADERCOLLECT.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 03:26:47 +03:00
|
|
|
}
|
2026-04-07 11:05:24 +03:00
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-08 10:23:48 +03:00
|
|
|
if is_dim {
|
|
|
|
|
// The edited override changed the dimension text; drop its
|
|
|
|
|
// stale *D block so save re-bakes it. (#181)
|
2026-07-24 23:25:33 +03:00
|
|
|
self.tabs[i].scene.invalidate_dim_block_recorded(handle);
|
2026-07-08 10:23:48 +03:00
|
|
|
}
|
2026-04-07 11:05:24 +03:00
|
|
|
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());
|
2026-04-07 11:05:24 +03:00
|
|
|
} else {
|
2026-07-24 23:25:33 +03:00
|
|
|
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());
|
2026-04-07 11:05:24 +03:00
|
|
|
}
|
|
|
|
|
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-03-22 22:44:32 +03:00
|
|
|
}
|
2026-08-05 00:08:35 +03:00
|
|
|
// 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.
|
2026-05-26 18:59:51 +03:00
|
|
|
if self.tabs[i].active_cmd.is_none() {
|
2026-03-31 00:31:28 +03:00
|
|
|
self.ribbon.deactivate_tool();
|
2026-03-22 22:44:32 +03:00
|
|
|
}
|
2026-08-05 00:08:35 +03:00
|
|
|
// 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();
|
|
|
|
|
}
|
2026-05-31 14:44:18 +03:00
|
|
|
// 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,
|
|
|
|
|
));
|
|
|
|
|
}
|
2026-05-26 18:59:51 +03:00
|
|
|
self.focus_cmd_input()
|
2026-03-22 22:44:32 +03:00
|
|
|
}
|
|
|
|
|
|
2026-06-16 09:53:32 +01:00
|
|
|
/// Restore the tangent-snap / ortho state that was in effect before the command started.
|
2026-06-18 14:34:32 +03:00
|
|
|
/// 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)
|
2026-08-05 21:37:56 +01:00
|
|
|
pub(super) fn merge_dependencies(
|
|
|
|
|
&mut self,
|
|
|
|
|
i: usize,
|
|
|
|
|
deps: &crate::app::ClipboardDeps,
|
|
|
|
|
) {
|
2026-06-18 14:34:32 +03:00
|
|
|
use acadrust::TableEntry;
|
2026-08-05 21:37:56 +01:00
|
|
|
if deps.is_empty() {
|
2026-06-18 14:34:32 +03:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
let doc = &mut self.tabs[i].scene.document;
|
2026-08-05 21:37:56 +01:00
|
|
|
for rec in &deps.layers {
|
2026-06-18 14:34:32 +03:00
|
|
|
if !doc.layers.contains(rec.name()) {
|
|
|
|
|
let mut r = rec.clone();
|
|
|
|
|
r.set_handle(doc.allocate_handle());
|
|
|
|
|
let _ = doc.layers.add(r);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-05 21:37:56 +01:00
|
|
|
for rec in &deps.linetypes {
|
2026-06-18 14:34:32 +03:00
|
|
|
if !doc.line_types.contains(rec.name()) {
|
|
|
|
|
let mut r = rec.clone();
|
|
|
|
|
r.set_handle(doc.allocate_handle());
|
|
|
|
|
let _ = doc.line_types.add(r);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-05 21:37:56 +01:00
|
|
|
for rec in &deps.text_styles {
|
2026-06-18 14:34:32 +03:00
|
|
|
if !doc.text_styles.contains(rec.name()) {
|
|
|
|
|
let mut r = rec.clone();
|
|
|
|
|
r.set_handle(doc.allocate_handle());
|
|
|
|
|
let _ = doc.text_styles.add(r);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-05 21:37:56 +01:00
|
|
|
for rec in &deps.dim_styles {
|
2026-06-18 14:34:32 +03:00
|
|
|
if !doc.dim_styles.contains(rec.name()) {
|
|
|
|
|
let mut r = rec.clone();
|
|
|
|
|
r.set_handle(doc.allocate_handle());
|
|
|
|
|
let _ = doc.dim_styles.add(r);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-05 21:37:56 +01:00
|
|
|
pub(super) fn merge_clipboard_deps(&mut self, i: usize) {
|
|
|
|
|
let deps = self.clipboard_deps.clone();
|
|
|
|
|
self.merge_dependencies(i, &deps);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 21:23:59 +03:00
|
|
|
/// 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 {
|
2026-07-24 20:49:05 +03:00
|
|
|
if self.tabs[i]
|
|
|
|
|
.scene
|
|
|
|
|
.document
|
|
|
|
|
.block_records
|
|
|
|
|
.get(&def.name)
|
|
|
|
|
.is_some()
|
|
|
|
|
{
|
2026-06-20 21:23:59 +03:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
self.tabs[i]
|
|
|
|
|
.scene
|
|
|
|
|
.define_block_raw(&def.name, def.base_point, def.entities);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-24 09:53:28 +03:00
|
|
|
/// 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);
|
|
|
|
|
}
|
2026-07-07 15:53:21 +02:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-24 09:53:28 +03:00
|
|
|
self.tabs[i].scene.add_entity_clone(entity)
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
2026-08-02 12:00:15 +03:00
|
|
|
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);
|
2026-08-13 11:58:54 -03:00
|
|
|
// 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);
|
|
|
|
|
}
|
2026-07-23 15:09:06 +03:00
|
|
|
// 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);
|
|
|
|
|
}
|
2026-07-08 16:08:19 +02:00
|
|
|
// 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();
|
2026-06-24 09:53:28 +03:00
|
|
|
by_index
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-24 09:33:55 +03:00
|
|
|
/// 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.
|
2026-08-02 12:00:15 +03:00
|
|
|
pub(super) fn merge_clipboard_ext_objects(
|
|
|
|
|
&mut self,
|
|
|
|
|
i: usize,
|
|
|
|
|
by_index: &[Handle],
|
|
|
|
|
annotation_delta: glam::DVec3,
|
|
|
|
|
) {
|
2026-06-24 09:33:55 +03:00
|
|
|
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;
|
2026-06-24 09:53:28 +03:00
|
|
|
for cap in &captures {
|
2026-06-24 09:33:55 +03:00
|
|
|
let Some(&new_entity) = by_index.get(cap.entity_index) else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
if new_entity.is_null() {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-06-24 09:53:28 +03:00
|
|
|
if let Some(new_root) = recreate_ext_subtree(doc, cap, Some(new_entity)) {
|
2026-06-24 09:33:55 +03:00
|
|
|
if let Some(e) = doc.get_entity_mut(new_entity) {
|
|
|
|
|
e.common_mut().xdictionary_handle = Some(new_root);
|
|
|
|
|
}
|
2026-08-02 12:00:15 +03:00
|
|
|
crate::scene::annotative::translate_annotation_contexts(
|
|
|
|
|
doc,
|
|
|
|
|
new_entity,
|
|
|
|
|
annotation_delta,
|
|
|
|
|
);
|
2026-06-24 09:33:55 +03:00
|
|
|
}
|
|
|
|
|
}
|
2026-07-28 17:16:27 +03:00
|
|
|
// 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);
|
2026-06-24 09:33:55 +03:00
|
|
|
}
|
|
|
|
|
|
2026-06-24 09:53:28 +03:00
|
|
|
/// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-22 22:44:32 +03:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-16 09:53:32 +01:00
|
|
|
if self.rect_suppressed_ortho {
|
|
|
|
|
self.rect_suppressed_ortho = false;
|
|
|
|
|
self.ortho_mode = true;
|
|
|
|
|
self.polar_mode = false;
|
|
|
|
|
}
|
2026-03-22 22:44:32 +03:00
|
|
|
}
|
|
|
|
|
}
|
feat: add missing Annotate tab commands and icons
New dimension commands: QDIM, DIMEDIT, DIMTEDIT, DIMBREAK, DIMSPACE,
DIMJOGLINE with full command workflows and SVG icons.
New multileader commands: MLEADERADD, MLEADERREMOVE, MLEADERALIGN,
MLEADERCOLLECT with entity injection pattern for in-place editing.
Updated annotate ribbon: all tools exposed across Text, Dimensions,
Leaders, Tables, Markup, and Annotation Scaling groups. Added STYLE,
FIND, DIMSTYLE shortcut buttons.
Added `inject_picked_entity` to the CadCommand trait for commands that
need a cloned copy of the picked entity to modify (DIMTEDIT, MLEADERADD,
MLEADERREMOVE). Sentinel-based dispatch in cmd_result.rs handles
DIMBREAK, DIMSPACE, DIMJOGLINE, MLEADERALIGN, and MLEADERCOLLECT.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 03:26:47 +03:00
|
|
|
|
2026-06-24 09:53:28 +03:00
|
|
|
/// 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);
|
|
|
|
|
}
|
2026-08-02 12:00:15 +03:00
|
|
|
for (old, scale) in &cap.annotation_scales {
|
|
|
|
|
let target = crate::scene::annotative::ensure_scale_object(doc, scale);
|
|
|
|
|
remap.insert(*old, target);
|
|
|
|
|
}
|
2026-06-24 09:53:28 +03:00
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 12:00:15 +03:00
|
|
|
/// 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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-24 09:33:55 +03:00
|
|
|
/// 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);
|
2026-08-02 12:00:15 +03:00
|
|
|
for entry in &mut x.entries {
|
|
|
|
|
if let acadrust::objects::XRecordValue::Handle(handle) = &mut entry.value {
|
|
|
|
|
*handle = map(*handle);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-24 09:33:55 +03:00
|
|
|
}
|
|
|
|
|
ObjectType::Group(g) => {
|
|
|
|
|
g.handle = new_handle;
|
|
|
|
|
g.owner = map(g.owner);
|
|
|
|
|
for h in g.entities.iter_mut() {
|
|
|
|
|
*h = map(*h);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-02 12:00:15 +03:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-24 09:33:55 +03:00
|
|
|
// 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).
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
feat: add missing Annotate tab commands and icons
New dimension commands: QDIM, DIMEDIT, DIMTEDIT, DIMBREAK, DIMSPACE,
DIMJOGLINE with full command workflows and SVG icons.
New multileader commands: MLEADERADD, MLEADERREMOVE, MLEADERALIGN,
MLEADERCOLLECT with entity injection pattern for in-place editing.
Updated annotate ribbon: all tools exposed across Text, Dimensions,
Leaders, Tables, Markup, and Annotation Scaling groups. Added STYLE,
FIND, DIMSTYLE shortcut buttons.
Added `inject_picked_entity` to the CadCommand trait for commands that
need a cloned copy of the picked entity to modify (DIMTEDIT, MLEADERADD,
MLEADERREMOVE). Sentinel-based dispatch in cmd_result.rs handles
DIMBREAK, DIMSPACE, DIMJOGLINE, MLEADERALIGN, and MLEADERCOLLECT.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 03:26:47 +03:00
|
|
|
// ── 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;
|
|
|
|
|
}
|
feat: add missing Annotate tab commands and icons
New dimension commands: QDIM, DIMEDIT, DIMTEDIT, DIMBREAK, DIMSPACE,
DIMJOGLINE with full command workflows and SVG icons.
New multileader commands: MLEADERADD, MLEADERREMOVE, MLEADERALIGN,
MLEADERCOLLECT with entity injection pattern for in-place editing.
Updated annotate ribbon: all tools exposed across Text, Dimensions,
Leaders, Tables, Markup, and Annotation Scaling groups. Added STYLE,
FIND, DIMSTYLE shortcut buttons.
Added `inject_picked_entity` to the CadCommand trait for commands that
need a cloned copy of the picked entity to modify (DIMTEDIT, MLEADERADD,
MLEADERREMOVE). Sentinel-based dispatch in cmd_result.rs handles
DIMBREAK, DIMSPACE, DIMJOGLINE, MLEADERALIGN, and MLEADERCOLLECT.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 03:26:47 +03:00
|
|
|
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(';')
|
feat: add missing Annotate tab commands and icons
New dimension commands: QDIM, DIMEDIT, DIMTEDIT, DIMBREAK, DIMSPACE,
DIMJOGLINE with full command workflows and SVG icons.
New multileader commands: MLEADERADD, MLEADERREMOVE, MLEADERALIGN,
MLEADERCOLLECT with entity injection pattern for in-place editing.
Updated annotate ribbon: all tools exposed across Text, Dimensions,
Leaders, Tables, Markup, and Annotation Scaling groups. Added STYLE,
FIND, DIMSTYLE shortcut buttons.
Added `inject_picked_entity` to the CadCommand trait for commands that
need a cloned copy of the picked entity to modify (DIMTEDIT, MLEADERADD,
MLEADERREMOVE). Sentinel-based dispatch in cmd_result.rs handles
DIMBREAK, DIMSPACE, DIMJOGLINE, MLEADERALIGN, and MLEADERCOLLECT.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 03:26:47 +03:00
|
|
|
.filter_map(|s| s.parse::<u64>().ok())
|
|
|
|
|
.collect();
|
|
|
|
|
let spacing: f64 = parts[2].parse().unwrap_or(0.0);
|
|
|
|
|
|
2026-06-27 17:56:01 +03:00
|
|
|
use acadrust::entities::Dimension;
|
feat: add missing Annotate tab commands and icons
New dimension commands: QDIM, DIMEDIT, DIMTEDIT, DIMBREAK, DIMSPACE,
DIMJOGLINE with full command workflows and SVG icons.
New multileader commands: MLEADERADD, MLEADERREMOVE, MLEADERALIGN,
MLEADERCOLLECT with entity injection pattern for in-place editing.
Updated annotate ribbon: all tools exposed across Text, Dimensions,
Leaders, Tables, Markup, and Annotation Scaling groups. Added STYLE,
FIND, DIMSTYLE shortcut buttons.
Added `inject_picked_entity` to the CadCommand trait for commands that
need a cloned copy of the picked entity to modify (DIMTEDIT, MLEADERADD,
MLEADERREMOVE). Sentinel-based dispatch in cmd_result.rs handles
DIMBREAK, DIMSPACE, DIMJOGLINE, MLEADERALIGN, and MLEADERCOLLECT.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 03:26:47 +03:00
|
|
|
let base_h = acadrust::Handle::from(base_val);
|
2026-06-27 17:56:01 +03:00
|
|
|
// 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,
|
feat: add missing Annotate tab commands and icons
New dimension commands: QDIM, DIMEDIT, DIMTEDIT, DIMBREAK, DIMSPACE,
DIMJOGLINE with full command workflows and SVG icons.
New multileader commands: MLEADERADD, MLEADERREMOVE, MLEADERALIGN,
MLEADERCOLLECT with entity injection pattern for in-place editing.
Updated annotate ribbon: all tools exposed across Text, Dimensions,
Leaders, Tables, Markup, and Annotation Scaling groups. Added STYLE,
FIND, DIMSTYLE shortcut buttons.
Added `inject_picked_entity` to the CadCommand trait for commands that
need a cloned copy of the picked entity to modify (DIMTEDIT, MLEADERADD,
MLEADERREMOVE). Sentinel-based dispatch in cmd_result.rs handles
DIMBREAK, DIMSPACE, DIMJOGLINE, MLEADERALIGN, and MLEADERCOLLECT.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 03:26:47 +03:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let effective_spacing = if spacing <= 0.0 { 10.0 } else { spacing };
|
2026-07-28 17:16:27 +03:00
|
|
|
let mut changes = Vec::new();
|
feat: add missing Annotate tab commands and icons
New dimension commands: QDIM, DIMEDIT, DIMTEDIT, DIMBREAK, DIMSPACE,
DIMJOGLINE with full command workflows and SVG icons.
New multileader commands: MLEADERADD, MLEADERREMOVE, MLEADERALIGN,
MLEADERCOLLECT with entity injection pattern for in-place editing.
Updated annotate ribbon: all tools exposed across Text, Dimensions,
Leaders, Tables, Markup, and Annotation Scaling groups. Added STYLE,
FIND, DIMSTYLE shortcut buttons.
Added `inject_picked_entity` to the CadCommand trait for commands that
need a cloned copy of the picked entity to modify (DIMTEDIT, MLEADERADD,
MLEADERREMOVE). Sentinel-based dispatch in cmd_result.rs handles
DIMBREAK, DIMSPACE, DIMJOGLINE, MLEADERALIGN, and MLEADERCOLLECT.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 03:26:47 +03:00
|
|
|
for (idx, &hv) in other_vals.iter().enumerate() {
|
|
|
|
|
let h = acadrust::Handle::from(hv);
|
2026-06-27 17:56:01 +03:00
|
|
|
let target = base_coord + effective_spacing * (idx + 1) as f64;
|
2026-07-28 17:16:27 +03:00
|
|
|
let mut changed = false;
|
2026-06-27 17:56:01 +03:00
|
|
|
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;
|
2026-07-28 17:16:27 +03:00
|
|
|
changed = true;
|
2026-06-27 17:56:01 +03:00
|
|
|
}
|
|
|
|
|
Dimension::Aligned(d) => {
|
|
|
|
|
slide(&mut d.definition_point);
|
|
|
|
|
d.base.definition_point = d.definition_point;
|
2026-07-28 17:16:27 +03:00
|
|
|
changed = true;
|
2026-06-27 17:56:01 +03:00
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
feat: add missing Annotate tab commands and icons
New dimension commands: QDIM, DIMEDIT, DIMTEDIT, DIMBREAK, DIMSPACE,
DIMJOGLINE with full command workflows and SVG icons.
New multileader commands: MLEADERADD, MLEADERREMOVE, MLEADERALIGN,
MLEADERCOLLECT with entity injection pattern for in-place editing.
Updated annotate ribbon: all tools exposed across Text, Dimensions,
Leaders, Tables, Markup, and Annotation Scaling groups. Added STYLE,
FIND, DIMSTYLE shortcut buttons.
Added `inject_picked_entity` to the CadCommand trait for commands that
need a cloned copy of the picked entity to modify (DIMTEDIT, MLEADERADD,
MLEADERREMOVE). Sentinel-based dispatch in cmd_result.rs handles
DIMBREAK, DIMSPACE, DIMJOGLINE, MLEADERALIGN, and MLEADERCOLLECT.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 03:26:47 +03:00
|
|
|
}
|
2026-07-28 17:16:27 +03:00
|
|
|
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);
|
feat: add missing Annotate tab commands and icons
New dimension commands: QDIM, DIMEDIT, DIMTEDIT, DIMBREAK, DIMSPACE,
DIMJOGLINE with full command workflows and SVG icons.
New multileader commands: MLEADERADD, MLEADERREMOVE, MLEADERALIGN,
MLEADERCOLLECT with entity injection pattern for in-place editing.
Updated annotate ribbon: all tools exposed across Text, Dimensions,
Leaders, Tables, Markup, and Annotation Scaling groups. Added STYLE,
FIND, DIMSTYLE shortcut buttons.
Added `inject_picked_entity` to the CadCommand trait for commands that
need a cloned copy of the picked entity to modify (DIMTEDIT, MLEADERADD,
MLEADERREMOVE). Sentinel-based dispatch in cmd_result.rs handles
DIMBREAK, DIMSPACE, DIMJOGLINE, MLEADERALIGN, and MLEADERCOLLECT.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 03:26:47 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 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(',')
|
feat: add missing Annotate tab commands and icons
New dimension commands: QDIM, DIMEDIT, DIMTEDIT, DIMBREAK, DIMSPACE,
DIMJOGLINE with full command workflows and SVG icons.
New multileader commands: MLEADERADD, MLEADERREMOVE, MLEADERALIGN,
MLEADERCOLLECT with entity injection pattern for in-place editing.
Updated annotate ribbon: all tools exposed across Text, Dimensions,
Leaders, Tables, Markup, and Annotation Scaling groups. Added STYLE,
FIND, DIMSTYLE shortcut buttons.
Added `inject_picked_entity` to the CadCommand trait for commands that
need a cloned copy of the picked entity to modify (DIMTEDIT, MLEADERADD,
MLEADERREMOVE). Sentinel-based dispatch in cmd_result.rs handles
DIMBREAK, DIMSPACE, DIMJOGLINE, MLEADERALIGN, and MLEADERCOLLECT.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 03:26:47 +03:00
|
|
|
.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;
|
|
|
|
|
}
|
feat: add missing Annotate tab commands and icons
New dimension commands: QDIM, DIMEDIT, DIMTEDIT, DIMBREAK, DIMSPACE,
DIMJOGLINE with full command workflows and SVG icons.
New multileader commands: MLEADERADD, MLEADERREMOVE, MLEADERALIGN,
MLEADERCOLLECT with entity injection pattern for in-place editing.
Updated annotate ribbon: all tools exposed across Text, Dimensions,
Leaders, Tables, Markup, and Annotation Scaling groups. Added STYLE,
FIND, DIMSTYLE shortcut buttons.
Added `inject_picked_entity` to the CadCommand trait for commands that
need a cloned copy of the picked entity to modify (DIMTEDIT, MLEADERADD,
MLEADERREMOVE). Sentinel-based dispatch in cmd_result.rs handles
DIMBREAK, DIMSPACE, DIMJOGLINE, MLEADERALIGN, and MLEADERCOLLECT.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 03:26:47 +03:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
feat: add missing Annotate tab commands and icons
New dimension commands: QDIM, DIMEDIT, DIMTEDIT, DIMBREAK, DIMSPACE,
DIMJOGLINE with full command workflows and SVG icons.
New multileader commands: MLEADERADD, MLEADERREMOVE, MLEADERALIGN,
MLEADERCOLLECT with entity injection pattern for in-place editing.
Updated annotate ribbon: all tools exposed across Text, Dimensions,
Leaders, Tables, Markup, and Annotation Scaling groups. Added STYLE,
FIND, DIMSTYLE shortcut buttons.
Added `inject_picked_entity` to the CadCommand trait for commands that
need a cloned copy of the picked entity to modify (DIMTEDIT, MLEADERADD,
MLEADERREMOVE). Sentinel-based dispatch in cmd_result.rs handles
DIMBREAK, DIMSPACE, DIMJOGLINE, MLEADERALIGN, and MLEADERCOLLECT.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 03:26:47 +03:00
|
|
|
|
|
|
|
|
// 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(',')
|
feat: add missing Annotate tab commands and icons
New dimension commands: QDIM, DIMEDIT, DIMTEDIT, DIMBREAK, DIMSPACE,
DIMJOGLINE with full command workflows and SVG icons.
New multileader commands: MLEADERADD, MLEADERREMOVE, MLEADERALIGN,
MLEADERCOLLECT with entity injection pattern for in-place editing.
Updated annotate ribbon: all tools exposed across Text, Dimensions,
Leaders, Tables, Markup, and Annotation Scaling groups. Added STYLE,
FIND, DIMSTYLE shortcut buttons.
Added `inject_picked_entity` to the CadCommand trait for commands that
need a cloned copy of the picked entity to modify (DIMTEDIT, MLEADERADD,
MLEADERREMOVE). Sentinel-based dispatch in cmd_result.rs handles
DIMBREAK, DIMSPACE, DIMJOGLINE, MLEADERALIGN, and MLEADERCOLLECT.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 03:26:47 +03:00
|
|
|
.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;
|
|
|
|
|
}
|
feat: add missing Annotate tab commands and icons
New dimension commands: QDIM, DIMEDIT, DIMTEDIT, DIMBREAK, DIMSPACE,
DIMJOGLINE with full command workflows and SVG icons.
New multileader commands: MLEADERADD, MLEADERREMOVE, MLEADERALIGN,
MLEADERCOLLECT with entity injection pattern for in-place editing.
Updated annotate ribbon: all tools exposed across Text, Dimensions,
Leaders, Tables, Markup, and Annotation Scaling groups. Added STYLE,
FIND, DIMSTYLE shortcut buttons.
Added `inject_picked_entity` to the CadCommand trait for commands that
need a cloned copy of the picked entity to modify (DIMTEDIT, MLEADERADD,
MLEADERREMOVE). Sentinel-based dispatch in cmd_result.rs handles
DIMBREAK, DIMSPACE, DIMJOGLINE, MLEADERALIGN, and MLEADERCOLLECT.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 03:26:47 +03:00
|
|
|
|
|
|
|
|
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..]);
|
|
|
|
|
}
|
feat(matchprop): match every style-affecting property (#281)
MATCHPROP copied only the general properties (and text style/height),
so matching dimension-to-dimension or hatch-to-hatch looked like it did
nothing — the visible differences live in type-specific style fields.
General set gains transparency. match_special_props transfers every
STYLE-affecting field the destination supports — never content or
placement:
- Text: style, height, width factor, oblique (style+height cross
TEXT <-> MTEXT); MText adds line spacing, drawing direction and the
background fill block.
- Dimension / Leader / Tolerance: the dim style name crosses all
three, and the ACAD_DSTYLE override record is replicated through
set_entity_xdata (cleared on the destination when the source carries
none, so the match is exact). Restyled dimensions drop their baked
*D block and repaint immediately.
- Hatch: pattern definition, type, angle, scale, solid flag and
gradient — the boundary stays.
- LwPolyline: constant width (vertex widths included) and linetype
generation.
- MultiLeader: style plus all style overrides (path type, line
color/type/weight, landing/dogleg, arrowhead, text style/color/
frame/height, overall scale, override flags); leader geometry, text
and block content stay.
Closes #281
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:24:53 +03:00
|
|
|
|
|
|
|
|
/// 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 {
|
2026-07-28 19:53:39 +03:00
|
|
|
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),
|
|
|
|
|
)),
|
feat(matchprop): match every style-affecting property (#281)
MATCHPROP copied only the general properties (and text style/height),
so matching dimension-to-dimension or hatch-to-hatch looked like it did
nothing — the visible differences live in type-specific style fields.
General set gains transparency. match_special_props transfers every
STYLE-affecting field the destination supports — never content or
placement:
- Text: style, height, width factor, oblique (style+height cross
TEXT <-> MTEXT); MText adds line spacing, drawing direction and the
background fill block.
- Dimension / Leader / Tolerance: the dim style name crosses all
three, and the ACAD_DSTYLE override record is replicated through
set_entity_xdata (cleared on the destination when the source carries
none, so the match is exact). Restyled dimensions drop their baked
*D block and repaint immediately.
- Hatch: pattern definition, type, angle, scale, solid flag and
gradient — the boundary stays.
- LwPolyline: constant width (vertex widths included) and linetype
generation.
- MultiLeader: style plus all style overrides (path type, line
color/type/weight, landing/dogleg, arrowhead, text style/color/
frame/height, overall scale, override flags); leader geometry, text
and block content stay.
Closes #281
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:24:53 +03:00
|
|
|
_ => None,
|
|
|
|
|
};
|
|
|
|
|
if let Some((style, height, wf, ob)) = text_fmt {
|
|
|
|
|
match dst {
|
|
|
|
|
E::Text(t) => {
|
2026-07-28 19:53:39 +03:00
|
|
|
t.style = style.clone();
|
feat(matchprop): match every style-affecting property (#281)
MATCHPROP copied only the general properties (and text style/height),
so matching dimension-to-dimension or hatch-to-hatch looked like it did
nothing — the visible differences live in type-specific style fields.
General set gains transparency. match_special_props transfers every
STYLE-affecting field the destination supports — never content or
placement:
- Text: style, height, width factor, oblique (style+height cross
TEXT <-> MTEXT); MText adds line spacing, drawing direction and the
background fill block.
- Dimension / Leader / Tolerance: the dim style name crosses all
three, and the ACAD_DSTYLE override record is replicated through
set_entity_xdata (cleared on the destination when the source carries
none, so the match is exact). Restyled dimensions drop their baked
*D block and repaint immediately.
- Hatch: pattern definition, type, angle, scale, solid flag and
gradient — the boundary stays.
- LwPolyline: constant width (vertex widths included) and linetype
generation.
- MultiLeader: style plus all style overrides (path type, line
color/type/weight, landing/dogleg, arrowhead, text style/color/
frame/height, overall scale, override flags); leader geometry, text
and block content stay.
Closes #281
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:24:53 +03:00
|
|
|
t.height = height;
|
2026-07-28 19:53:39 +03:00
|
|
|
if let Some(value) = wf {
|
|
|
|
|
t.width_factor = value;
|
|
|
|
|
}
|
|
|
|
|
if let Some(value) = ob {
|
|
|
|
|
t.oblique_angle = value;
|
feat(matchprop): match every style-affecting property (#281)
MATCHPROP copied only the general properties (and text style/height),
so matching dimension-to-dimension or hatch-to-hatch looked like it did
nothing — the visible differences live in type-specific style fields.
General set gains transparency. match_special_props transfers every
STYLE-affecting field the destination supports — never content or
placement:
- Text: style, height, width factor, oblique (style+height cross
TEXT <-> MTEXT); MText adds line spacing, drawing direction and the
background fill block.
- Dimension / Leader / Tolerance: the dim style name crosses all
three, and the ACAD_DSTYLE override record is replicated through
set_entity_xdata (cleared on the destination when the source carries
none, so the match is exact). Restyled dimensions drop their baked
*D block and repaint immediately.
- Hatch: pattern definition, type, angle, scale, solid flag and
gradient — the boundary stays.
- LwPolyline: constant width (vertex widths included) and linetype
generation.
- MultiLeader: style plus all style overrides (path type, line
color/type/weight, landing/dogleg, arrowhead, text style/color/
frame/height, overall scale, override flags); leader geometry, text
and block content stay.
Closes #281
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:24:53 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
E::MText(m) => {
|
2026-07-28 19:53:39 +03:00
|
|
|
m.style = style.clone();
|
feat(matchprop): match every style-affecting property (#281)
MATCHPROP copied only the general properties (and text style/height),
so matching dimension-to-dimension or hatch-to-hatch looked like it did
nothing — the visible differences live in type-specific style fields.
General set gains transparency. match_special_props transfers every
STYLE-affecting field the destination supports — never content or
placement:
- Text: style, height, width factor, oblique (style+height cross
TEXT <-> MTEXT); MText adds line spacing, drawing direction and the
background fill block.
- Dimension / Leader / Tolerance: the dim style name crosses all
three, and the ACAD_DSTYLE override record is replicated through
set_entity_xdata (cleared on the destination when the source carries
none, so the match is exact). Restyled dimensions drop their baked
*D block and repaint immediately.
- Hatch: pattern definition, type, angle, scale, solid flag and
gradient — the boundary stays.
- LwPolyline: constant width (vertex widths included) and linetype
generation.
- MultiLeader: style plus all style overrides (path type, line
color/type/weight, landing/dogleg, arrowhead, text style/color/
frame/height, overall scale, override flags); leader geometry, text
and block content stay.
Closes #281
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:24:53 +03:00
|
|
|
m.height = height;
|
|
|
|
|
}
|
2026-07-28 19:53:39 +03:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
feat(matchprop): match every style-affecting property (#281)
MATCHPROP copied only the general properties (and text style/height),
so matching dimension-to-dimension or hatch-to-hatch looked like it did
nothing — the visible differences live in type-specific style fields.
General set gains transparency. match_special_props transfers every
STYLE-affecting field the destination supports — never content or
placement:
- Text: style, height, width factor, oblique (style+height cross
TEXT <-> MTEXT); MText adds line spacing, drawing direction and the
background fill block.
- Dimension / Leader / Tolerance: the dim style name crosses all
three, and the ACAD_DSTYLE override record is replicated through
set_entity_xdata (cleared on the destination when the source carries
none, so the match is exact). Restyled dimensions drop their baked
*D block and repaint immediately.
- Hatch: pattern definition, type, angle, scale, solid flag and
gradient — the boundary stays.
- LwPolyline: constant width (vertex widths included) and linetype
generation.
- MultiLeader: style plus all style overrides (path type, line
color/type/weight, landing/dogleg, arrowhead, text style/color/
frame/height, overall scale, override flags); leader geometry, text
and block content stay.
Closes #281
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:24:53 +03:00
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// 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;
|
2026-07-28 19:53:39 +03:00
|
|
|
dh.is_double = sh.is_double;
|
|
|
|
|
dh.style = sh.style;
|
feat(matchprop): match every style-affecting property (#281)
MATCHPROP copied only the general properties (and text style/height),
so matching dimension-to-dimension or hatch-to-hatch looked like it did
nothing — the visible differences live in type-specific style fields.
General set gains transparency. match_special_props transfers every
STYLE-affecting field the destination supports — never content or
placement:
- Text: style, height, width factor, oblique (style+height cross
TEXT <-> MTEXT); MText adds line spacing, drawing direction and the
background fill block.
- Dimension / Leader / Tolerance: the dim style name crosses all
three, and the ACAD_DSTYLE override record is replicated through
set_entity_xdata (cleared on the destination when the source carries
none, so the match is exact). Restyled dimensions drop their baked
*D block and repaint immediately.
- Hatch: pattern definition, type, angle, scale, solid flag and
gradient — the boundary stays.
- LwPolyline: constant width (vertex widths included) and linetype
generation.
- MultiLeader: style plus all style overrides (path type, line
color/type/weight, landing/dogleg, arrowhead, text style/color/
frame/height, overall scale, override flags); leader geometry, text
and block content stay.
Closes #281
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:24:53 +03:00
|
|
|
dh.gradient_color = sh.gradient_color.clone();
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-28 19:53:39 +03:00
|
|
|
// 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();
|
feat(matchprop): match every style-affecting property (#281)
MATCHPROP copied only the general properties (and text style/height),
so matching dimension-to-dimension or hatch-to-hatch looked like it did
nothing — the visible differences live in type-specific style fields.
General set gains transparency. match_special_props transfers every
STYLE-affecting field the destination supports — never content or
placement:
- Text: style, height, width factor, oblique (style+height cross
TEXT <-> MTEXT); MText adds line spacing, drawing direction and the
background fill block.
- Dimension / Leader / Tolerance: the dim style name crosses all
three, and the ACAD_DSTYLE override record is replicated through
set_entity_xdata (cleared on the destination when the source carries
none, so the match is exact). Restyled dimensions drop their baked
*D block and repaint immediately.
- Hatch: pattern definition, type, angle, scale, solid flag and
gradient — the boundary stays.
- LwPolyline: constant width (vertex widths included) and linetype
generation.
- MultiLeader: style plus all style overrides (path type, line
color/type/weight, landing/dogleg, arrowhead, text style/color/
frame/height, overall scale, override flags); leader geometry, text
and block content stay.
Closes #281
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:24:53 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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;
|
2026-07-28 19:53:39 +03:00
|
|
|
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;
|
feat(matchprop): match every style-affecting property (#281)
MATCHPROP copied only the general properties (and text style/height),
so matching dimension-to-dimension or hatch-to-hatch looked like it did
nothing — the visible differences live in type-specific style fields.
General set gains transparency. match_special_props transfers every
STYLE-affecting field the destination supports — never content or
placement:
- Text: style, height, width factor, oblique (style+height cross
TEXT <-> MTEXT); MText adds line spacing, drawing direction and the
background fill block.
- Dimension / Leader / Tolerance: the dim style name crosses all
three, and the ACAD_DSTYLE override record is replicated through
set_entity_xdata (cleared on the destination when the source carries
none, so the match is exact). Restyled dimensions drop their baked
*D block and repaint immediately.
- Hatch: pattern definition, type, angle, scale, solid flag and
gradient — the boundary stays.
- LwPolyline: constant width (vertex widths included) and linetype
generation.
- MultiLeader: style plus all style overrides (path type, line
color/type/weight, landing/dogleg, arrowhead, text style/color/
frame/height, overall scale, override flags); leader geometry, text
and block content stay.
Closes #281
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:24:53 +03:00
|
|
|
dm.scale_factor = sm.scale_factor;
|
|
|
|
|
dm.property_override_flags = sm.property_override_flags;
|
2026-07-28 19:53:39 +03:00
|
|
|
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;
|
feat(matchprop): match every style-affecting property (#281)
MATCHPROP copied only the general properties (and text style/height),
so matching dimension-to-dimension or hatch-to-hatch looked like it did
nothing — the visible differences live in type-specific style fields.
General set gains transparency. match_special_props transfers every
STYLE-affecting field the destination supports — never content or
placement:
- Text: style, height, width factor, oblique (style+height cross
TEXT <-> MTEXT); MText adds line spacing, drawing direction and the
background fill block.
- Dimension / Leader / Tolerance: the dim style name crosses all
three, and the ACAD_DSTYLE override record is replicated through
set_entity_xdata (cleared on the destination when the source carries
none, so the match is exact). Restyled dimensions drop their baked
*D block and repaint immediately.
- Hatch: pattern definition, type, angle, scale, solid flag and
gradient — the boundary stays.
- LwPolyline: constant width (vertex widths included) and linetype
generation.
- MultiLeader: style plus all style overrides (path type, line
color/type/weight, landing/dogleg, arrowhead, text style/color/
frame/height, overall scale, override flags); leader geometry, text
and block content stay.
Closes #281
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:24:53 +03:00
|
|
|
}
|
|
|
|
|
}
|
2026-07-28 19:53:39 +03:00
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
}
|