forked from 1b-agent/opencadstudio-upstream
Attribute Definition dialog and Edit Attribute Definition dialog, -ATTDEF with the reference prompts and modes (AFLAGS), tag shown in the drawing, justification-aware grips, reworked Properties with validation, Home Block slide-out and Insert Block Definition panel, and the codec patch for ATTDEF DXF output and the R2013+ solid wire cache. Co-Authored-By: Claude Opus 5.5 <[email protected]>
4694 lines
230 KiB
Rust
4694 lines
230 KiB
Rust
//! `command` arms and helpers, split out of the original `update.rs` (#mechanical decomposition).
|
||
|
||
#![allow(unused_imports)]
|
||
use super::util::*;
|
||
use super::{format_size, VIEWCUBE_HIT_SIZE};
|
||
use crate::app::helpers::{
|
||
parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis,
|
||
CoordKind,
|
||
};
|
||
use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS};
|
||
use crate::app::TextEntryMode;
|
||
use crate::modules::ModuleEvent;
|
||
use crate::scene::pick::grip::{
|
||
find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit, GripEditMode,
|
||
};
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
use crate::plugin::v4_support;
|
||
use crate::scene::model::object::{GripApply, PropValue};
|
||
use crate::scene::{
|
||
self, hover_id, CubeRegion, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX,
|
||
};
|
||
use crate::ui::PropertiesPanel;
|
||
use crate::ui::window::attribute_editor::{AttrRow, AttrTab};
|
||
use codec::types::Color as AcadColor;
|
||
use codec::{EntityType as AcadEntityType, Handle};
|
||
use iced::time::Instant;
|
||
use iced::{mouse, Point, Task};
|
||
|
||
/// Return `(new vertex grip id, old vertex count)` for polyline Add Vertex.
|
||
/// LWPolyline segment grips live after the vertex grips, so translate their id
|
||
/// back to the segment before selecting the inserted vertex.
|
||
fn polyline_add_vertex_target(entity: &AcadEntityType, grip_id: usize) -> Option<(usize, usize)> {
|
||
match entity {
|
||
AcadEntityType::LwPolyline(polyline) => {
|
||
let n = polyline.vertices.len();
|
||
if n == 0 {
|
||
None
|
||
} else if grip_id < n {
|
||
Some((grip_id + 1, n))
|
||
} else {
|
||
let segment = grip_id - n;
|
||
let segment_count = if polyline.is_closed {
|
||
n
|
||
} else {
|
||
n.saturating_sub(1)
|
||
};
|
||
(segment < segment_count).then_some((segment + 1, n))
|
||
}
|
||
}
|
||
AcadEntityType::Polyline(polyline) if grip_id < polyline.vertices.len() => {
|
||
Some((grip_id + 1, polyline.vertices.len()))
|
||
}
|
||
AcadEntityType::Polyline2D(polyline) if grip_id < polyline.vertices.len() => {
|
||
Some((grip_id + 1, polyline.vertices.len()))
|
||
}
|
||
AcadEntityType::Polyline3D(polyline) if grip_id < polyline.vertices.len() => {
|
||
Some((grip_id + 1, polyline.vertices.len()))
|
||
}
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
fn polyline_vertex_count(entity: &AcadEntityType) -> Option<usize> {
|
||
match entity {
|
||
AcadEntityType::LwPolyline(polyline) => Some(polyline.vertices.len()),
|
||
AcadEntityType::Polyline(polyline) => Some(polyline.vertices.len()),
|
||
AcadEntityType::Polyline2D(polyline) => Some(polyline.vertices.len()),
|
||
AcadEntityType::Polyline3D(polyline) => Some(polyline.vertices.len()),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Which text input, if any, holds focus when Ctrl+V arrives: a focused
|
||
/// text input pastes natively, so the shortcut must not paste again.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
#[derive(Default)]
|
||
struct PasteFocusProbe {
|
||
/// The id of the text input whose `focusable` call comes next.
|
||
pending: Option<Option<iced::advanced::widget::Id>>,
|
||
focus: Option<crate::app::PasteFocus>,
|
||
}
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
impl iced::advanced::widget::Operation<crate::app::PasteFocus> for PasteFocusProbe {
|
||
fn text_input(
|
||
&mut self,
|
||
id: Option<&iced::advanced::widget::Id>,
|
||
_bounds: iced::Rectangle,
|
||
_state: &mut dyn iced::advanced::widget::operation::TextInput,
|
||
) {
|
||
self.pending = Some(id.cloned());
|
||
}
|
||
|
||
fn focusable(
|
||
&mut self,
|
||
_id: Option<&iced::advanced::widget::Id>,
|
||
_bounds: iced::Rectangle,
|
||
state: &mut dyn iced::advanced::widget::operation::Focusable,
|
||
) {
|
||
// A text input reports `text_input` then `focusable`; other
|
||
// focusables (text editors) only the latter.
|
||
let Some(id) = self.pending.take() else {
|
||
return;
|
||
};
|
||
if state.is_focused() {
|
||
let command_line =
|
||
iced::advanced::widget::Id::new(crate::ui::command_line::CMD_INPUT_ID);
|
||
self.focus = Some(if id.as_ref() == Some(&command_line) {
|
||
crate::app::PasteFocus::CommandLine
|
||
} else {
|
||
crate::app::PasteFocus::Field
|
||
});
|
||
}
|
||
}
|
||
|
||
fn traverse(
|
||
&mut self,
|
||
operate: &mut dyn FnMut(&mut dyn iced::advanced::widget::Operation<crate::app::PasteFocus>),
|
||
) {
|
||
if self.focus.is_none() {
|
||
operate(self);
|
||
}
|
||
}
|
||
|
||
fn finish(&self) -> iced::advanced::widget::operation::Outcome<crate::app::PasteFocus> {
|
||
iced::advanced::widget::operation::Outcome::Some(
|
||
self.focus.unwrap_or(crate::app::PasteFocus::None),
|
||
)
|
||
}
|
||
}
|
||
|
||
impl OpenCADStudio {
|
||
pub(super) fn begin_tab_close_queue(&mut self, tab_ids: Vec<u64>) -> Task<Message> {
|
||
self.pending_tab_closes.clear();
|
||
self.pending_tab_closes.extend(tab_ids);
|
||
self.continue_tab_close_queue()
|
||
}
|
||
|
||
/// Close queued drawings until a dirty tab requires confirmation. Queue
|
||
/// entries use stable document ids because removing an earlier tab changes
|
||
/// every later vector index.
|
||
pub(super) fn continue_tab_close_queue(&mut self) -> Task<Message> {
|
||
let mut tasks = Vec::new();
|
||
while let Some(tab_id) = self.pending_tab_closes.pop_front() {
|
||
let Some(idx) = self.tabs.iter().position(|tab| tab.id == tab_id) else {
|
||
continue;
|
||
};
|
||
if self.tabs[idx].is_start {
|
||
continue;
|
||
}
|
||
if self.tabs[idx].dirty {
|
||
self.pending_close = Some(crate::app::PendingClose::Tab(tab_id));
|
||
tasks.push(self.open_unsaved_dialog_window());
|
||
break;
|
||
}
|
||
tasks.push(self.on_tab_close(idx));
|
||
}
|
||
Task::batch(tasks)
|
||
}
|
||
|
||
pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||
// Start tab is fixed — close requests on it are no-ops.
|
||
if self.tabs.get(idx).map_or(false, |t| t.is_start) {
|
||
return Task::none();
|
||
}
|
||
// Closing a tab shifts indices / the active tab. The attribute
|
||
// editor holds a document-local handle into one tab, so drop it
|
||
// now rather than risk it applying to a different tab's document.
|
||
self.cancel_attr_editor();
|
||
if self.tabs.get(idx).map_or(false, |t| t.dirty) {
|
||
self.pending_close =
|
||
Some(crate::app::PendingClose::Tab(self.tabs[idx].id));
|
||
return self.open_unsaved_dialog_window();
|
||
}
|
||
// Pending client interactive requests pinned to this drawing
|
||
// can never be answered once the tab is gone — cancel them.
|
||
let closing_tab_id = self.tabs.get(idx).map(|t| t.id);
|
||
let pinned = self
|
||
.control
|
||
.get_point
|
||
.as_ref()
|
||
.map(|s| s.document_id)
|
||
.filter(|&id| Some(id) == closing_tab_id)
|
||
.or_else(|| {
|
||
self.control
|
||
.user_select
|
||
.as_ref()
|
||
.map(|s| s.document_id)
|
||
.filter(|&id| Some(id) == closing_tab_id)
|
||
});
|
||
if pinned.is_some() {
|
||
self.resolve_get_point(None);
|
||
self.resolve_user_select(false);
|
||
}
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
let tab_id = self.tabs.get(idx).map(|t| t.id);
|
||
// This tab is closing for good — drop its autosave recovery copy.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
let _ = std::fs::remove_file(self.autosave_target(idx));
|
||
// Only-tab case: when the lone non-start tab closes, fall
|
||
// back to the Start tab if it exists; otherwise spawn a
|
||
// fresh blank drawing (legacy behaviour).
|
||
if self.tabs.len() == 1 {
|
||
self.tab_counter += 1;
|
||
self.tabs[0] = crate::app::document::DocumentTab::new_drawing(self.tab_counter);
|
||
self.active_tab = 0;
|
||
self.apply_display_defaults(0);
|
||
} else {
|
||
self.tabs.remove(idx);
|
||
if self.active_tab >= self.tabs.len() {
|
||
self.active_tab = self.tabs.len() - 1;
|
||
}
|
||
}
|
||
// The active tab is now either a brand-new blank or a
|
||
// different existing tab; in both cases the ribbon needs
|
||
// to track that doc's defaults / selection. #21.
|
||
self.sync_ribbon_layers();
|
||
self.sync_ribbon_styles();
|
||
self.sync_ribbon_from_selection();
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
if let Some(tab_id) = tab_id {
|
||
v4_support::on_tab_closed(tab_id);
|
||
}
|
||
Task::none()
|
||
}
|
||
|
||
pub(super) fn on_command_append_char(&mut self, s: String) -> Task<Message> {
|
||
// While the MText preview is up, typed glyphs edit it directly.
|
||
if self.mtext_editor.as_ref().is_some_and(|e| e.show_preview) {
|
||
if s.chars().all(|c| !c.is_control()) {
|
||
self.mtext_type(&s);
|
||
}
|
||
return Task::none();
|
||
}
|
||
// Filter out control characters — only push the typed
|
||
// glyph(s). `Tab`, etc. arrive as Named keys, not here.
|
||
if s.chars().all(|c| !c.is_control()) {
|
||
let i = self.active_tab;
|
||
// `,` is the coordinate separator in dynamic input,
|
||
// not a decimal point: typing it locks the current
|
||
// field's buffer and advances to the next coordinate,
|
||
// reshaping the field set when going polar → cartesian
|
||
// (Distance → X, Y) or 2-D → 3-D (X, Y → X, Y, Z).
|
||
// See #35.
|
||
if s == "," && self.dyn_input && !self.tabs[i].dyn_fields.is_empty() {
|
||
self.dyn_comma_advance();
|
||
self.command_line.autocomplete_cursor = None;
|
||
self.refresh_active_cmd_preview(i);
|
||
return self.focus_cmd_input();
|
||
}
|
||
// In dynamic point entry, `@` and `#` are coordinate-mode
|
||
// switches rather than command-line text. Consuming them
|
||
// here keeps the prefix out of the command buffer while
|
||
// the following digits continue into the dynamic fields.
|
||
if matches!(s.as_str(), "@" | "#")
|
||
&& self.command_line.input.is_empty()
|
||
&& self.dyn_input
|
||
&& self.dyn_has_coordinate_fields(i)
|
||
{
|
||
self.dyn_set_coordinate_mode(s == "#");
|
||
self.command_line.autocomplete_cursor = None;
|
||
self.refresh_active_cmd_preview(i);
|
||
return self.focus_cmd_input();
|
||
}
|
||
// While dynamic input is showing fields, numeric and
|
||
// expression glyphs edit the focused field instead of
|
||
// the command line. Letters still go to the command line
|
||
// so command-option keywords keep working.
|
||
let dyn_field_char = !s.is_empty()
|
||
&& s.chars().all(|c| {
|
||
c.is_ascii_digit()
|
||
|| matches!(c, '.' | '-' | '+' | '*' | '/' | '^' | '%' | '(' | ')')
|
||
});
|
||
if dyn_field_char
|
||
&& self.command_line.input.is_empty()
|
||
&& self.dyn_input
|
||
&& !self.tabs[i].dyn_fields.is_empty()
|
||
{
|
||
let a = self.tabs[i]
|
||
.dyn_active
|
||
.min(self.tabs[i].dyn_fields.len() - 1);
|
||
self.tabs[i].dyn_fields[a]
|
||
.buffer
|
||
.get_or_insert_with(String::new)
|
||
.push_str(&s);
|
||
if self.tabs[i]
|
||
.active_grip
|
||
.as_ref()
|
||
.is_some_and(|grip| grip.mode.uses_scalar_dynamic_input())
|
||
{
|
||
self.command_line.input.push_str(&s);
|
||
}
|
||
} else {
|
||
// Command-line entry is shown uppercase — except in
|
||
// free-form text prompts, where the typed case is the
|
||
// content (matches the CommandInput route).
|
||
if self.is_free_text_active() {
|
||
self.command_line.input.push_str(&s);
|
||
} else {
|
||
self.command_line.input.push_str(&s.to_uppercase());
|
||
}
|
||
self.command_line.cancel_history_navigation();
|
||
// Live incremental search for INSERT/MINSERT (see CommandInput)
|
||
let live = self.command_line.input.clone();
|
||
let i = self.active_tab;
|
||
let (should_update, opts, prompt) = if let Some(cmd) = self.tabs[i].active_cmd.as_mut() {
|
||
if cmd.on_live_input(&live) { (true, cmd.options(), cmd.prompt()) } else { (false, Vec::new(), String::new()) }
|
||
} else { (false, Vec::new(), String::new()) };
|
||
if should_update {
|
||
self.command_line.set_step_options(opts);
|
||
if let Some(last) = self.command_line.history.last_mut() { if last.pinned { last.text = prompt; } }
|
||
}
|
||
}
|
||
}
|
||
self.command_line.autocomplete_cursor = None;
|
||
self.focus_cmd_input()
|
||
}
|
||
|
||
pub(super) fn on_command_backspace(&mut self) -> Task<Message> {
|
||
if self.mtext_editor.as_ref().is_some_and(|e| e.show_preview) {
|
||
self.mtext_backspace();
|
||
return Task::none();
|
||
}
|
||
let i = self.active_tab;
|
||
// Backspace edits the focused dynamic-input field first;
|
||
// emptying it unlocks the field (back to cursor tracking).
|
||
if self.dyn_input && !self.tabs[i].dyn_fields.is_empty() {
|
||
let a = self.tabs[i]
|
||
.dyn_active
|
||
.min(self.tabs[i].dyn_fields.len() - 1);
|
||
if let Some(buf) = self.tabs[i].dyn_fields[a].buffer.as_mut() {
|
||
buf.pop();
|
||
if buf.is_empty() {
|
||
self.tabs[i].dyn_fields[a].buffer = None;
|
||
}
|
||
if self.tabs[i]
|
||
.active_grip
|
||
.as_ref()
|
||
.is_some_and(|grip| grip.mode.uses_scalar_dynamic_input())
|
||
{
|
||
self.command_line.input.pop();
|
||
}
|
||
return self.focus_cmd_input();
|
||
}
|
||
}
|
||
self.command_line.input.pop();
|
||
self.command_line.autocomplete_cursor = None;
|
||
self.command_line.cancel_history_navigation();
|
||
// Live incremental search for INSERT/MINSERT
|
||
{
|
||
let live = self.command_line.input.clone();
|
||
let i = self.active_tab;
|
||
let (should_update, opts, prompt) = if let Some(cmd) = self.tabs[i].active_cmd.as_mut() {
|
||
if cmd.on_live_input(&live) { (true, cmd.options(), cmd.prompt()) } else { (false, Vec::new(), String::new()) }
|
||
} else { (false, Vec::new(), String::new()) };
|
||
if should_update {
|
||
self.command_line.set_step_options(opts);
|
||
if let Some(last) = self.command_line.history.last_mut() { if last.pinned { last.text = prompt; } }
|
||
}
|
||
}
|
||
self.focus_cmd_input()
|
||
}
|
||
|
||
pub(super) fn on_command_submit(&mut self) -> Task<Message> {
|
||
// Submitting a command implicitly dismisses the history
|
||
// dropdown so the dispatched command's new prompt is
|
||
// immediately visible on the overlay.
|
||
self.command_line.close_history();
|
||
// A leading `>` was only a "literal spaces" typing hint (see
|
||
// CommandSpace) — drop it before the input is interpreted.
|
||
// Free-form text prompts keep it: there it is content, not a
|
||
// hint, so a cell value like `>Note` commits verbatim.
|
||
if !self.is_free_text_active() && self.command_line.input.starts_with('>') {
|
||
self.command_line.input.remove(0);
|
||
}
|
||
// Grip-menu value prompt — consume the typed number and
|
||
// route it through `apply_grip_menu_value`.
|
||
// Interactive grip prompts are valid only while their matching
|
||
// grip edit is alive. Do not let a stale Radius / Lengthen /
|
||
// Arc Length prompt consume Enter or coordinates from a later
|
||
// drawing command.
|
||
let stale_interactive_grip_prompt = self.grip_pending.as_ref().is_some_and(|pending| {
|
||
let expected_mode = match pending.action {
|
||
crate::scene::model::object::GripMenuAction::Lengthen => {
|
||
Some(GripEditMode::Lengthen)
|
||
}
|
||
crate::scene::model::object::GripMenuAction::Radius => {
|
||
Some(GripEditMode::Radius)
|
||
}
|
||
crate::scene::model::object::GripMenuAction::ArcLength => {
|
||
Some(GripEditMode::ArcLength)
|
||
}
|
||
crate::scene::model::object::GripMenuAction::RectangleWidth => {
|
||
Some(GripEditMode::RectangleWidth)
|
||
}
|
||
crate::scene::model::object::GripMenuAction::RectangleHeight => {
|
||
Some(GripEditMode::RectangleHeight)
|
||
}
|
||
crate::scene::model::object::GripMenuAction::MoveParallel => {
|
||
Some(GripEditMode::MoveParallel)
|
||
}
|
||
_ => None,
|
||
};
|
||
expected_mode.is_some_and(|mode| {
|
||
!self.tabs[self.active_tab].active_grip.as_ref().is_some_and(|grip| {
|
||
grip.mode == mode
|
||
&& grip.handle == pending.handle
|
||
&& grip.grip_id == pending.grip_id
|
||
})
|
||
})
|
||
});
|
||
if stale_interactive_grip_prompt {
|
||
self.grip_pending = None;
|
||
}
|
||
if let Some(pending) = self.grip_pending.take() {
|
||
let i = self.active_tab;
|
||
if self.reject_locked_edit(i, pending.handle) {
|
||
self.cancel_active_grip_edit();
|
||
return Task::none();
|
||
}
|
||
// Dynamic Input keeps numeric typing in its focused field.
|
||
// Fall back to the command-line buffer so the established
|
||
// prompt workflow remains unchanged when DYN is disabled.
|
||
let dyn_value = self.tabs[i]
|
||
.dyn_fields
|
||
.iter()
|
||
.find_map(|field| field.buffer.as_deref());
|
||
let entered = dyn_value.unwrap_or(self.command_line.input.trim());
|
||
let raw = crate::app::expr_eval::eval_to_string(entered);
|
||
self.command_line.input.clear();
|
||
let Ok(v) = raw.parse::<f64>() else {
|
||
self.command_line.push_error(crate::tf!(
|
||
"{}: expected a number, got \"{raw}\"",
|
||
pending.label
|
||
).as_ref());
|
||
self.grip_pending = Some(pending);
|
||
return self.focus_cmd_input();
|
||
};
|
||
let interactive_value_grip = self.tabs[i]
|
||
.active_grip
|
||
.as_ref()
|
||
.is_some_and(|grip| {
|
||
matches!(
|
||
(grip.mode, pending.action),
|
||
(
|
||
GripEditMode::Lengthen,
|
||
crate::scene::model::object::GripMenuAction::Lengthen,
|
||
) | (
|
||
GripEditMode::Radius,
|
||
crate::scene::model::object::GripMenuAction::Radius,
|
||
) | (
|
||
GripEditMode::ArcLength,
|
||
crate::scene::model::object::GripMenuAction::ArcLength,
|
||
) | (
|
||
GripEditMode::RectangleWidth,
|
||
crate::scene::model::object::GripMenuAction::RectangleWidth,
|
||
) | (
|
||
GripEditMode::RectangleHeight,
|
||
crate::scene::model::object::GripMenuAction::RectangleHeight,
|
||
) | (
|
||
GripEditMode::MoveParallel,
|
||
crate::scene::model::object::GripMenuAction::MoveParallel,
|
||
)
|
||
)
|
||
&& grip.handle == pending.handle
|
||
&& grip.grip_id == pending.grip_id
|
||
});
|
||
if interactive_value_grip {
|
||
self.cancel_active_grip_edit();
|
||
}
|
||
use crate::entities::traits::EntityTypeOps;
|
||
self.push_undo_snapshot(i, pending.label);
|
||
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(pending.handle)
|
||
{
|
||
entity.apply_grip_menu_value(pending.grip_id, pending.action, v);
|
||
}
|
||
// A typed grip-menu value reshapes dimensions too — drop a
|
||
// stale baked *D block (no-op for non-dims). (#398)
|
||
self.tabs[i]
|
||
.scene
|
||
.invalidate_dim_block_recorded(pending.handle);
|
||
self.tabs[i].scene.bump_entities(&[(
|
||
pending.handle,
|
||
crate::scene::ChangeKind::Modified,
|
||
)]);
|
||
self.tabs[i].dirty = true;
|
||
self.refresh_selected_grips();
|
||
self.refresh_properties();
|
||
return Task::none();
|
||
}
|
||
|
||
// Numeric entry while a normal grip stretch is active.
|
||
//
|
||
// With Dynamic Input enabled, typed values live in the shared
|
||
// Distance / Angle fields. Resolve those fields into an exact
|
||
// world point. Without DYN input, preserve the existing direct-
|
||
// distance command-line behaviour.
|
||
{
|
||
let i = self.active_tab;
|
||
|
||
if let Some(grip) = self.tabs[i].active_grip.clone() {
|
||
if matches!(grip.mode, GripEditMode::Stretch | GripEditMode::RectangleResize) {
|
||
let dyn_locked = self.tabs[i]
|
||
.dyn_fields
|
||
.iter()
|
||
.any(|field| field.buffer.is_some());
|
||
|
||
let target = if matches!(grip.mode, GripEditMode::RectangleResize) {
|
||
grip.rectangle_frame.map(|(opposite, width_axis, height_axis)| {
|
||
let cursor_delta = self.tabs[i].last_cursor_world - opposite;
|
||
let mut width = cursor_delta.dot(width_axis);
|
||
let mut height = cursor_delta.dot(height_axis);
|
||
for field in &self.tabs[i].dyn_fields {
|
||
let value = field
|
||
.buffer
|
||
.as_ref()
|
||
.and_then(|buffer| crate::app::expr_eval::eval_number(buffer));
|
||
if let Some(value) = value {
|
||
match field.role {
|
||
crate::command::DynRole::Width => {
|
||
width = value.abs().copysign(width);
|
||
}
|
||
crate::command::DynRole::Height => {
|
||
height = value.abs().copysign(height);
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
opposite + width_axis * width + height_axis * height
|
||
})
|
||
} else if dyn_locked {
|
||
// Distance only:
|
||
// keep the cursor's current direction.
|
||
//
|
||
// Distance + Angle:
|
||
// resolve both typed values from the grip's
|
||
// original position (`dyn_anchor`).
|
||
self.dyn_resolve_point()
|
||
} else {
|
||
// Legacy command-line direct-distance entry.
|
||
let text = crate::app::expr_eval::eval_to_string(
|
||
self.command_line.input.trim(),
|
||
);
|
||
|
||
crate::app::expr_eval::eval_number(text.trim()).map(|dist| {
|
||
let cursor = self.tabs[i].last_cursor_world;
|
||
|
||
// If the cursor is following OTRACK or Extension,
|
||
// preserve the existing reference-ray behaviour.
|
||
if let Some((base, dir)) = self.active_distance_ray(i) {
|
||
base + dir * dist
|
||
} else {
|
||
let direction = cursor - grip.origin_world;
|
||
let len = direction.length();
|
||
|
||
if len <= 1e-12 {
|
||
grip.origin_world
|
||
} else {
|
||
grip.origin_world + direction / len * dist
|
||
}
|
||
}
|
||
})
|
||
};
|
||
|
||
if let Some(target) = target {
|
||
// Typed Dynamic Input can commit a grip before the mouse has moved.
|
||
//
|
||
// Normally the first ViewportMove initializes the grip preview handles and
|
||
// snapshots the original entities. Without that move the document changes,
|
||
// so the refreshed grips move, but the resident wire tessellation is never
|
||
// invalidated by the normal grip-finalization path.
|
||
//
|
||
// Seed the same bookkeeping here before modifying the entities.
|
||
if self.grip_preview_handles.is_empty() {
|
||
let mut seen_handles = rustc_hash::FxHashSet::default();
|
||
|
||
let edited_handles: Vec<_> = grip
|
||
.targets
|
||
.iter()
|
||
.map(|target| target.handle)
|
||
.filter(|handle| seen_handles.insert(*handle))
|
||
.collect();
|
||
|
||
if self.grip_dirty_before.is_none() {
|
||
self.grip_dirty_before = Some(self.tabs[i].dirty);
|
||
}
|
||
|
||
if self.grip_originals.is_empty() {
|
||
self.grip_originals = edited_handles
|
||
.iter()
|
||
.filter_map(|&handle| {
|
||
self.tabs[i]
|
||
.scene
|
||
.document
|
||
.get_entity(handle)
|
||
.cloned()
|
||
.map(|entity| (handle, entity))
|
||
})
|
||
.collect();
|
||
}
|
||
|
||
self.capture_grip_history_originals(i, &edited_handles);
|
||
|
||
self.grip_preview_handles = edited_handles;
|
||
}
|
||
let delta = target - grip.last_world;
|
||
|
||
let actions: Vec<_> = if let Some((opposite, width_axis, height_axis)) = grip.rectangle_frame {
|
||
let opposite_id = (grip.grip_id + 2) % 4;
|
||
let mut edits = Vec::with_capacity(3);
|
||
for adjacent_id in [(opposite_id + 1) % 4, (opposite_id + 3) % 4] {
|
||
if let Some(original) = self.tabs[i]
|
||
.selected_grip_handles
|
||
.iter()
|
||
.zip(self.tabs[i].selected_grips.iter())
|
||
.find(|(owner, candidate)| **owner == grip.handle && candidate.id == adjacent_id)
|
||
.map(|(_, candidate)| candidate.world)
|
||
{
|
||
let d = original - opposite;
|
||
let axis = if d.dot(width_axis).abs() >= d.dot(height_axis).abs() {
|
||
width_axis
|
||
} else {
|
||
height_axis
|
||
};
|
||
edits.push((grip.handle, adjacent_id, GripApply::Absolute(
|
||
opposite + axis * (target - opposite).dot(axis),
|
||
)));
|
||
}
|
||
}
|
||
edits.push((grip.handle, grip.grip_id, GripApply::Absolute(target)));
|
||
edits
|
||
} else {
|
||
grip.targets.iter().map(|target_grip| {
|
||
let apply = if target_grip.is_translate {
|
||
GripApply::Translate(delta)
|
||
} else {
|
||
GripApply::Absolute(
|
||
target_grip.last_world + delta,
|
||
)
|
||
};
|
||
|
||
(
|
||
target_grip.handle,
|
||
target_grip.grip_id,
|
||
apply,
|
||
)
|
||
}).collect()
|
||
};
|
||
|
||
for (handle, grip_id, apply) in actions {
|
||
self.tabs[i]
|
||
.scene
|
||
.apply_grip(handle, grip_id, apply);
|
||
}
|
||
self.solve_grip_constraints(i, &grip);
|
||
|
||
// Keep GripEdit synchronized so the normal commit
|
||
// path records the exact final position.
|
||
if let Some(active) = self.tabs[i].active_grip.as_mut() {
|
||
active.last_world = target;
|
||
|
||
for target_grip in &mut active.targets {
|
||
target_grip.last_world += delta;
|
||
}
|
||
}
|
||
|
||
self.tabs[i].last_cursor_world = target;
|
||
self.command_line.input.clear();
|
||
|
||
// Consume the typed Dynamic Input values.
|
||
for field in &mut self.tabs[i].dyn_fields {
|
||
field.buffer = None;
|
||
}
|
||
self.tabs[i].dyn_active = 0;
|
||
self.dyn_user_reshaped = false;
|
||
self.dyn_coord_absolute = false;
|
||
|
||
self.tabs[i].dirty = true;
|
||
|
||
// Reuse the existing grip finalization path:
|
||
// undo grouping, preview restoration, cleanup, etc.
|
||
let task = self.on_viewport_left_release();
|
||
|
||
// active_grip is now gone, so remove the temporary
|
||
// Distance / Angle fields as well.
|
||
self.sync_dyn_fields();
|
||
|
||
// A typed grip commit bypasses the normal viewport point-release
|
||
// tracking cleanup. Drop the consumed OTRACK / Extension guide now
|
||
// so no stale tracking ray remains visible after the grip edit.
|
||
self.reset_tracking_after_point();
|
||
|
||
return task;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Interactive VPORTS: the entry after a bare `VPORTS` is the
|
||
// tiled configuration. Empty input defaults to SINGLE.
|
||
if self.awaiting_vports {
|
||
self.awaiting_vports = false;
|
||
let cfg = self.command_line.input.trim().to_string();
|
||
self.command_line.input.clear();
|
||
let cfg = if cfg.is_empty() {
|
||
"SINGLE".to_string()
|
||
} else {
|
||
cfg
|
||
};
|
||
return self.dispatch_command(&format!("VPORTS {cfg}"));
|
||
}
|
||
// Interactive system-variable value: after a bare `MIRRTEXT`
|
||
// (etc.) the next entry is the new value; empty Enter keeps the
|
||
// current setting.
|
||
if let Some(name) = self.pending_setvar.take() {
|
||
let val = self.command_line.input.trim().to_string();
|
||
self.command_line.input.clear();
|
||
if val.is_empty() {
|
||
return Task::none();
|
||
}
|
||
return self.dispatch_command(&format!("SETVAR {name} {val}"));
|
||
}
|
||
let i_tab = self.active_tab;
|
||
if self.tabs[i_tab].active_cmd.is_none() {
|
||
if let Some(command) = self.command_line.selected_suggestion() {
|
||
self.command_line.input.clear();
|
||
self.command_line.autocomplete_cursor = None;
|
||
return self.dispatch_command(&command);
|
||
}
|
||
}
|
||
let i = self.active_tab;
|
||
// A whole multi-token command line (`UCS Z 90`, `LINE 0,0
|
||
// 10,10`, `PDMODE 3`) — typable now that Space is literal — is
|
||
// processed as one unit: feed the tokens to a running command,
|
||
// or start a new one through the shared runner that the headless
|
||
// automation feeder uses too.
|
||
{
|
||
// Skip token-splitting when the active command collects
|
||
// free-form text with spaces (TEXT / MTEXT / a name) — it
|
||
// wants the whole line as one input.
|
||
let wants_spaces = self.tabs[i]
|
||
.active_cmd
|
||
.as_ref()
|
||
.map(|c| c.is_free_text_step())
|
||
.unwrap_or(false);
|
||
let raw = self.command_line.input.clone();
|
||
let toks: Vec<String> = raw.split_whitespace().map(String::from).collect();
|
||
if toks.len() > 1 && !wants_spaces {
|
||
self.command_line.input.clear();
|
||
if self.tabs[i].active_cmd.is_some() {
|
||
let mut tasks = Vec::new();
|
||
for tok in &toks {
|
||
if self.tabs[i].active_cmd.is_none() {
|
||
break;
|
||
}
|
||
tasks.push(self.feed_active_cmd(tok));
|
||
}
|
||
return Task::batch(tasks);
|
||
}
|
||
return self.run_command_line(&raw);
|
||
}
|
||
}
|
||
// With the command line empty, a typed dynamic-input value
|
||
// commits as a point pick instead of an empty submit.
|
||
if self.tabs[i].active_cmd.is_some() && self.command_line.input.trim().is_empty() {
|
||
if let Some(task) = self.try_dyn_commit() {
|
||
return task;
|
||
}
|
||
}
|
||
if self.tabs[i].active_cmd.is_some() {
|
||
// Free-form text is the content itself: no expression
|
||
// evaluation, or a cell value like `5*2` would commit as
|
||
// `10`. Single-token prompts keep the calculator behavior.
|
||
let free_text = self.tabs[i]
|
||
.active_cmd
|
||
.as_ref()
|
||
.map(|c| c.is_free_text_step())
|
||
.unwrap_or(false);
|
||
let raw = self.command_line.input.trim().to_string();
|
||
let is_mtp = raw.trim_start_matches('_').eq_ignore_ascii_case("MTP")
|
||
|| raw.trim_start_matches('_').eq_ignore_ascii_case("M2P");
|
||
if is_mtp {
|
||
let is_point_step = !self.tabs[i]
|
||
.active_cmd
|
||
.as_ref()
|
||
.map(|c| c.input_kind().wants_text())
|
||
.unwrap_or(true)
|
||
|| self.tabs[i]
|
||
.active_cmd
|
||
.as_ref()
|
||
.map(|c| c.point_step_accepts_keywords())
|
||
.unwrap_or(false);
|
||
let not_entity_pick = !self.tabs[i]
|
||
.active_cmd
|
||
.as_ref()
|
||
.map(|c| c.needs_entity_pick())
|
||
.unwrap_or(false);
|
||
if is_point_step && not_entity_pick {
|
||
self.command_line.input.clear();
|
||
self.start_mtp_modifier(i);
|
||
return self.focus_cmd_input();
|
||
}
|
||
}
|
||
let text = if free_text {
|
||
raw
|
||
} else {
|
||
crate::app::expr_eval::eval_to_string(&raw)
|
||
};
|
||
self.command_line.input.clear();
|
||
// Remember the token for the context menu's Recent Input
|
||
// list (prose steps excluded: a table cell or text body
|
||
// is not a reusable value).
|
||
let is_prose = self.tabs[i]
|
||
.active_cmd
|
||
.as_ref()
|
||
.is_some_and(|c| c.input_kind().is_free_text());
|
||
if !is_prose {
|
||
self.command_line.record_recent_input(&text);
|
||
}
|
||
|
||
// Offer the typed text to the command's option handler
|
||
// first (keywords like PLINE's A/L/C, a radius, …). If it
|
||
// consumes the text we're done; if it returns None the
|
||
// text falls through to the Enter / coordinate handling
|
||
// below, so a bare Enter still terminates and typed points
|
||
// still work when dynamic input is off. See #97.
|
||
// Selection keywords (P / L) at a Select objects prompt
|
||
// consume the token before any other interpretation (#426).
|
||
if let Some(task) = self.try_selection_keyword(&text) {
|
||
return task;
|
||
}
|
||
if self.tabs[i]
|
||
.active_cmd
|
||
.as_ref()
|
||
.map(|c| c.input_kind().wants_text())
|
||
.unwrap_or(false)
|
||
{
|
||
// An empty submit must not be handed to on_text_input —
|
||
// a text step would commit the empty string (wiping a
|
||
// table cell, clearing an attribute). It falls through
|
||
// to the Enter handling below, which ends the step the
|
||
// same way a bare Enter does: unchanged.
|
||
if !text.is_empty() {
|
||
self.push_ucs_to_cmd(i);
|
||
if let Some(result) = self.tabs[i]
|
||
.active_cmd
|
||
.as_mut()
|
||
.and_then(|c| c.on_text_input(&text))
|
||
{
|
||
return self.apply_cmd_result(result);
|
||
}
|
||
}
|
||
}
|
||
|
||
if text.is_empty() {
|
||
return self.feed_command(crate::command::StepInput::Enter);
|
||
}
|
||
|
||
// OTRACK and Extension both allow a bare scalar to act as a
|
||
// distance measured along the active reference ray.
|
||
if self.active_distance_ray(i).is_some() {
|
||
if let Some(task) = self.try_direct_distance_entry(&text) {
|
||
return task;
|
||
}
|
||
}
|
||
|
||
if let Some((coord, kind)) = parse_coord(&text) {
|
||
// Command-line coordinates are absolute by default,
|
||
// independent of DYN: `@` forces relative, `#` forces
|
||
// absolute, and a bare value is absolute. (Relative-by-
|
||
// default lives in the DYN tooltip path — see
|
||
// `dyn_resolve_point`; command-line coordinates stay
|
||
// absolute regardless of the DYN setting.)
|
||
let want_relative = matches!(kind, CoordKind::Relative);
|
||
let ucs = self.tabs[i].active_ucs.clone();
|
||
let wcs_pt = match (want_relative, self.last_point) {
|
||
(true, Some(base)) => {
|
||
// Offset from the last point, rotated by the
|
||
// UCS axes (no origin translation).
|
||
let offset = match &ucs {
|
||
Some(u) => ucs_rotate_vec(coord, u),
|
||
None => coord,
|
||
};
|
||
base + offset
|
||
}
|
||
_ => {
|
||
// Absolute: typed coordinates are in active UCS.
|
||
match &ucs {
|
||
Some(u) => ucs_to_wcs(coord, u),
|
||
None => coord,
|
||
}
|
||
}
|
||
};
|
||
if !self.command_point_allowed(i, wcs_pt) {
|
||
return Task::none();
|
||
}
|
||
self.last_point = Some(wcs_pt);
|
||
self.dyn_user_reshaped = false;
|
||
self.dyn_coord_absolute = false;
|
||
self.sync_dyn_fields();
|
||
self.reset_tracking_after_point();
|
||
self.push_ucs_to_cmd(i);
|
||
let result = self.tabs[i].active_cmd.as_mut().map(|c| c.on_point(wcs_pt));
|
||
if let Some(r) = result {
|
||
let task = self.apply_cmd_result(r);
|
||
// The rubber-band preview that the command
|
||
// last published reflects the *previous*
|
||
// last_point — a typed coordinate doesn't
|
||
// fire a mouse-move, so re-run the preview
|
||
// hook now using the current cursor world
|
||
// pos so the next segment immediately starts
|
||
// from the just-committed point. See #32.
|
||
self.refresh_active_cmd_preview(i);
|
||
return task;
|
||
}
|
||
return Task::none();
|
||
}
|
||
|
||
self.push_ucs_to_cmd(i);
|
||
if let Some(result) = self.tabs[i]
|
||
.active_cmd
|
||
.as_mut()
|
||
.and_then(|c| c.on_text_input(&text))
|
||
{
|
||
return self.apply_cmd_result(result);
|
||
}
|
||
|
||
// Direct distance entry: if an anchor exists and a scalar distance was entered,
|
||
// project along the active reference ray (if any) or the current cursor direction
|
||
// in the active UCS plane.
|
||
if let Some(task) = self.try_direct_distance_entry(&text) {
|
||
return task;
|
||
}
|
||
|
||
self.command_line.push_error(crate::tf!(
|
||
"Expected Cartesian, polar, cylindrical or spherical coordinates, or a number; got: \"{text}\""
|
||
).as_ref());
|
||
return self.focus_cmd_input();
|
||
}
|
||
if let Some(cmd) = self.command_line.submit() {
|
||
return self.dispatch_command_or_suggest(&cmd);
|
||
}
|
||
// Empty Enter with a pending client user_select request is the
|
||
// person's answer: hand the picked set back to the caller
|
||
// before the repeat-last-command shortcut can fire.
|
||
if self.control.user_select.is_some() {
|
||
self.resolve_user_select(true);
|
||
return Task::none();
|
||
}
|
||
// Empty Enter / Space with no active command repeats the
|
||
// last dispatched command — same shortcut `CommandFinalize`
|
||
// already implements, mirrored here so the trailing-space
|
||
// submit path goes through it too.
|
||
if let Some(cmd) = self.tabs[i].last_cmd.clone() {
|
||
return self.dispatch_command(&cmd);
|
||
}
|
||
Task::none()
|
||
}
|
||
|
||
/// Attempt direct distance entry: if an active command is expecting a point,
|
||
/// has an established anchor, and `text` parses as a scalar distance,
|
||
/// project along the active reference ray (if any) or the current cursor
|
||
/// direction in the active UCS plane.
|
||
pub(in crate::app) fn try_direct_distance_entry(&mut self, text: &str) -> Option<Task<Message>> {
|
||
let i = self.active_tab;
|
||
if self.tabs[i]
|
||
.active_cmd
|
||
.as_ref()
|
||
.is_some_and(|c| c.needs_entity_pick())
|
||
{
|
||
return None;
|
||
}
|
||
|
||
let dist = crate::entities::common::parse_length(text.trim())
|
||
.or_else(|| crate::app::expr_eval::eval_number(text.trim()))?;
|
||
|
||
// A tracking ray carries its own base, so it needs no anchor: that is
|
||
// how OTRACK and Extension accepted a bare distance for the *first*
|
||
// point of a command, before any anchor exists.
|
||
let pt = if let Some((base, dir)) = self.active_distance_ray(i) {
|
||
base + dir * dist
|
||
} else {
|
||
let anchor = self.tabs[i]
|
||
.active_cmd
|
||
.as_ref()
|
||
.and_then(|c| c.resolved_anchor())
|
||
.or(self.tabs[i].dyn_anchor)
|
||
.or(self.last_point)?;
|
||
let w = self.tabs[i].last_cursor_world;
|
||
let xf = self.tabs[i].ucs_xform();
|
||
let d_ucs = xf.vec_to_ucs(w - anchor);
|
||
let dx = d_ucs.x;
|
||
let dy = d_ucs.y;
|
||
let dir_ucs = if (dx * dx + dy * dy) > 1e-12 {
|
||
glam::DVec3::new(dx, dy, 0.0).normalize()
|
||
} else if d_ucs.length_squared() > 1e-12 {
|
||
d_ucs.normalize()
|
||
} else {
|
||
glam::DVec3::X
|
||
};
|
||
anchor + xf.vec_to_wcs(dir_ucs * dist)
|
||
};
|
||
|
||
if !self.command_point_allowed(i, pt) {
|
||
return Some(Task::none());
|
||
}
|
||
|
||
self.last_point = Some(pt);
|
||
self.dyn_user_reshaped = false;
|
||
self.dyn_coord_absolute = false;
|
||
self.sync_dyn_fields();
|
||
self.reset_tracking_after_point();
|
||
self.push_ucs_to_cmd(i);
|
||
|
||
let result = self.tabs[i].active_cmd.as_mut().map(|c| c.on_point(pt))?;
|
||
let task = self.apply_cmd_result(result);
|
||
self.refresh_active_cmd_preview(i);
|
||
Some(task)
|
||
}
|
||
|
||
/// The active command's current step collects free-form prose from the
|
||
/// command line. Single decision point for the routes that type into
|
||
/// the buffer (CommandInput, Shift+Enter, the view's on_submit wiring).
|
||
pub(crate) fn is_free_text_active(&self) -> bool {
|
||
self.tabs[self.active_tab]
|
||
.active_cmd
|
||
.as_ref()
|
||
.is_some_and(|c| c.is_free_text_step())
|
||
}
|
||
|
||
/// What Space / Enter currently mean. Previously every key handler
|
||
/// re-derived this from the MText editor and the active command, and
|
||
/// the MText preview case was known only to `CommandSpace` — so Enter
|
||
/// behaved differently depending on which route carried the key.
|
||
pub(crate) fn text_entry_mode(&self) -> crate::app::TextEntryMode {
|
||
if self.mtext_editor.as_ref().is_some_and(|e| e.show_preview) {
|
||
return TextEntryMode::MTextPreview;
|
||
}
|
||
if self.is_free_text_active() {
|
||
return TextEntryMode::FreeText;
|
||
}
|
||
TextEntryMode::Command
|
||
}
|
||
|
||
pub(super) fn on_command_finalize(&mut self) -> Task<Message> {
|
||
// In the MText preview, Enter inserts a line break. The
|
||
// free-text prompt case falls through deliberately: for it
|
||
// Enter *finishes* the edit (Shift+Enter breaks the line,
|
||
// handled by the SHIFT+ENTER shortcut route).
|
||
if self.text_entry_mode() == TextEntryMode::MTextPreview {
|
||
self.mtext_type("\n");
|
||
return Task::none();
|
||
}
|
||
// Grip popup open → Enter commits the highlighted item.
|
||
if self.grip_popup.is_some() {
|
||
let idx = self.grip_popup.as_ref().map(|p| p.selected).unwrap_or(0);
|
||
return Task::done(Message::GripMenuPick(idx));
|
||
}
|
||
// Any typed command-line text must be submitted rather than
|
||
// finalising. The focused-input Enter routes through
|
||
// CommandSubmit, but when the field isn't focused — e.g. at
|
||
// startup before the window grabs focus, or a command started
|
||
// from the ribbon — its Enter arrives here. Forward a non-empty
|
||
// buffer to the same submit path so typing a command name (or
|
||
// an option keyword like "R") and pressing Enter works without
|
||
// first clicking into the command line (issue #99).
|
||
if !self.command_line.input.trim().is_empty() {
|
||
return self.update(Message::CommandSubmit);
|
||
}
|
||
// A grip edit is not an active CAD command, but its Dynamic Input
|
||
// fields use the same keyboard path. Route Enter through CommandSubmit,
|
||
// whose grip branch resolves Distance / Angle and finalizes the edit.
|
||
let i = self.active_tab;
|
||
let grip_dyn_locked = self.tabs[i].active_grip.is_some()
|
||
&& self.dyn_input
|
||
&& self.tabs[i]
|
||
.dyn_fields
|
||
.iter()
|
||
.any(|field| field.locked());
|
||
|
||
if grip_dyn_locked {
|
||
return self.update(Message::CommandSubmit);
|
||
}
|
||
// Enter while a grip is hot places it where it is (or keeps
|
||
// it hot when it has not moved) — it must not fall through to
|
||
// "repeat the last command", which would discard the edit.
|
||
if self.tabs[i].active_grip.is_some() && self.tabs[i].active_cmd.is_none() {
|
||
return self.commit_active_grip_edit();
|
||
}
|
||
|
||
// Normal command Dynamic Input commit.
|
||
if let Some(task) = self.try_dyn_commit() {
|
||
return task;
|
||
}
|
||
|
||
let i = self.active_tab;
|
||
if self.tabs[i].active_cmd.is_some() {
|
||
self.feed_command(crate::command::StepInput::Enter)
|
||
} else if self.control.user_select.is_some() {
|
||
// Enter with no command active answers a pending client
|
||
// user_select request — same contract as the command-line
|
||
// empty-submit path above.
|
||
self.resolve_user_select(true);
|
||
Task::none()
|
||
} else if let Some(cmd) = self.tabs[i].last_cmd.clone() {
|
||
self.dispatch_command(&cmd)
|
||
} else {
|
||
Task::none()
|
||
}
|
||
}
|
||
|
||
pub(super) fn on_command_escape(&mut self) -> Task<Message> {
|
||
// Escape answers a pending client getpoint request first: no point.
|
||
if self.control.get_point.is_some() {
|
||
self.resolve_get_point(None);
|
||
return Task::none();
|
||
}
|
||
// Escape answers a pending client user_select request before anything
|
||
// else — the person on the screen is who the request is waiting for.
|
||
if self.control.user_select.is_some() {
|
||
self.resolve_user_select(false);
|
||
return Task::none();
|
||
}
|
||
if self.ribbon.escape_extension() {
|
||
return Task::none();
|
||
}
|
||
// Esc drops an unconsumed one-shot snap override and closes
|
||
// its menu (#337). Falls through — Esc keeps its usual effect.
|
||
self.snap_override_popup = None;
|
||
self.snapper.clear_override();
|
||
// Open MText editor swallows Escape (cancel without committing).
|
||
if self.mtext_editor.is_some() {
|
||
self.mtext_cancel();
|
||
return self.post_editor_closed(false);
|
||
}
|
||
// The in-place TEXT editor likewise cancels on Escape.
|
||
if self.text_inline.is_some() {
|
||
self.text_inline_cancel();
|
||
return self.post_editor_closed(false);
|
||
}
|
||
// Esc cancels an armed pane move.
|
||
if self.pane_move_from.take().is_some() {
|
||
return Task::none();
|
||
}
|
||
// Esc cancels a pending system-variable value prompt.
|
||
if self.pending_setvar.take().is_some() {
|
||
self.command_line.push_info(crate::t!("*Cancel*").as_ref());
|
||
return Task::none();
|
||
}
|
||
// UCS icon: Esc ends any grip drag and clears the selection
|
||
// (only when no command owns Escape).
|
||
if self.tabs[self.active_tab].active_cmd.is_none()
|
||
&& (self.ucs_grip_drag.is_some() || self.ucs_icon_selected)
|
||
{
|
||
let i = self.active_tab;
|
||
// A UCS grip drag is live-only until mouse release. Reload
|
||
// the persisted pane UCS so Escape really cancels it.
|
||
if self.ucs_grip_drag.take().is_some() {
|
||
self.tabs[i].refresh_active_ucs();
|
||
self.tabs[i].scene.camera_generation += 1;
|
||
}
|
||
self.ucs_icon_selected = false;
|
||
self.ucs_icon_hover = false;
|
||
self.tabs[i].snap_result = None;
|
||
return Task::none();
|
||
}
|
||
// Leave an interactive navigation mode and end its in-flight
|
||
// drag. Orbit exits silently; PAN keeps its existing message.
|
||
if self.tabs[self.active_tab].pan_mode
|
||
|| self.tabs[self.active_tab].orbit_mode
|
||
|| self.tabs[self.active_tab].zoom_dynamic_mode
|
||
{
|
||
let i = self.active_tab;
|
||
let was_pan = self.tabs[i].pan_mode;
|
||
self.tabs[i].pan_mode = false;
|
||
self.tabs[i].orbit_mode = false;
|
||
self.tabs[i].zoom_dynamic_mode = false;
|
||
{
|
||
let mut sel = self.tabs[i].scene.selection.borrow_mut();
|
||
sel.middle_down = false;
|
||
sel.middle_last_pos = None;
|
||
sel.orbit_pivot = None;
|
||
sel.box_anchor = None;
|
||
sel.box_anchor_world = None;
|
||
sel.box_current = None;
|
||
sel.box_crossing_locked = false;
|
||
}
|
||
if was_pan {
|
||
self.command_line.push_output(crate::t!("PAN ended.").as_ref());
|
||
}
|
||
self.ribbon.deactivate_tool();
|
||
return Task::none();
|
||
}
|
||
// Grip popup intercepts Escape — dismisses the menu
|
||
// without doing anything else.
|
||
if self.grip_popup.take().is_some() {
|
||
self.grip_hover = None;
|
||
return Task::none();
|
||
}
|
||
if self.visibility_popup.take().is_some() {
|
||
return Task::none();
|
||
}
|
||
if self.tabs[self.active_tab].active_grip.is_some() {
|
||
self.grip_pending = None;
|
||
self.command_line.input.clear();
|
||
if self.cancel_active_grip_edit() {
|
||
return Task::none();
|
||
}
|
||
}
|
||
if self.grip_pending.take().is_some() {
|
||
self.command_line.input.clear();
|
||
return Task::none();
|
||
}
|
||
// A hot grip (click-move-click placement in progress) rolls
|
||
// back to its pre-drag image on Escape.
|
||
if self.cancel_active_grip_edit() {
|
||
return Task::none();
|
||
}
|
||
// Cancel layout rename first, then fall through.
|
||
let i_e = self.active_tab;
|
||
if let Some(state) = self.qselect.take() {
|
||
self.qselect_settings = Some((&state).into());
|
||
self.reset_modal_geometry();
|
||
if self.block_definition.is_some() {
|
||
self.active_modal = Some(crate::app::ModalKind::BlockDefinition);
|
||
} else if self.wblock.is_some() {
|
||
self.active_modal = Some(crate::app::ModalKind::WriteBlock);
|
||
}
|
||
return Task::none();
|
||
}
|
||
{
|
||
let mut sel = self.tabs[i_e].scene.selection.borrow_mut();
|
||
if sel.context_menu.is_some() {
|
||
sel.context_menu = None;
|
||
return Task::none();
|
||
}
|
||
}
|
||
if self.layout_rename_state.take().is_some() {
|
||
return Task::none();
|
||
}
|
||
// Typed text on the command line cancels first — one
|
||
// Esc empties the buffer, a second Esc then escalates
|
||
// to whatever the current mode would otherwise do
|
||
// (cancel command / exit viewport / deselect).
|
||
if !self.command_line.input.is_empty() {
|
||
self.command_line.input.clear();
|
||
self.command_line.autocomplete_cursor = None;
|
||
self.command_line.close_history();
|
||
return Task::none();
|
||
}
|
||
let i = self.active_tab;
|
||
if self.tabs[i].active_cmd.is_some() {
|
||
let result = self.tabs[i].active_cmd.as_mut().map(|c| c.on_escape());
|
||
if let Some(r) = result {
|
||
return self.apply_cmd_result(r);
|
||
}
|
||
} else if self.tabs[i].scene.active_viewport.is_some() {
|
||
// ESC while in MSPACE → exit back to paper space.
|
||
return Task::done(Message::ExitViewport);
|
||
} else {
|
||
self.tabs[i].scene.deselect_all();
|
||
self.refresh_properties();
|
||
let mut sel = self.tabs[i].scene.selection.borrow_mut();
|
||
sel.box_anchor = None;
|
||
// Also drop the world-space anchor, or the next pan/zoom
|
||
// re-projects it back into `box_anchor` and the cancelled
|
||
// marquee springs back to life (reproject_box_anchor).
|
||
sel.box_anchor_world = None;
|
||
sel.box_current = None;
|
||
sel.box_crossing = false;
|
||
}
|
||
Task::none()
|
||
}
|
||
|
||
pub(super) fn on_layer_toggle_vp_freeze(&mut self, layer_idx: usize, vp_col_idx: usize) -> Task<Message> {
|
||
let i = self.active_tab;
|
||
let vp_handle = self.tabs[i]
|
||
.layers
|
||
.vp_cols
|
||
.get(vp_col_idx)
|
||
.map(|c| c.handle);
|
||
let layer_name = self.tabs[i]
|
||
.layers
|
||
.layers
|
||
.get(layer_idx)
|
||
.map(|l| l.name.clone());
|
||
|
||
if let (Some(vp_handle), Some(layer_name)) = (vp_handle, layer_name) {
|
||
// Get the layer handle from the document
|
||
if let Some(doc_layer) = self.tabs[i].scene.document.layers.get(&layer_name) {
|
||
let layer_handle = doc_layer.handle;
|
||
self.push_undo_snapshot(i, "VPLAYER");
|
||
|
||
// Toggle frozen_layers on the viewport entity
|
||
for e in self.tabs[i].scene.document.entities_mut() {
|
||
if let codec::EntityType::Viewport(vp) = e {
|
||
if vp.common.handle == vp_handle {
|
||
if vp.frozen_layers.contains(&layer_handle) {
|
||
vp.frozen_layers.retain(|h| h != &layer_handle);
|
||
} else {
|
||
vp.frozen_layers.push(layer_handle);
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Re-sync layer panel with updated VP info
|
||
let vp_info = self.tabs[i].scene.viewport_list();
|
||
let doc_layers = self.tabs[i].scene.document.layers.clone();
|
||
self.tabs[i]
|
||
.layers
|
||
.sync_with_viewports(&doc_layers, vp_info);
|
||
// Per-viewport layer visibility changes the resident
|
||
// assembly, not any entity's tessellated geometry.
|
||
self.tabs[i].scene.bump_geometry_no_blocks();
|
||
self.tabs[i].dirty = true;
|
||
}
|
||
}
|
||
Task::none()
|
||
}
|
||
|
||
pub(super) fn on_layer_new(&mut self) -> Task<Message> {
|
||
let i = self.active_tab;
|
||
let mut n = 1;
|
||
let new_name = loop {
|
||
let candidate = format!("Layer{}", n);
|
||
if !self.tabs[i].scene.document.layers.contains(&candidate) {
|
||
break candidate;
|
||
}
|
||
n += 1;
|
||
};
|
||
self.push_undo_snapshot(i, "LAYER NEW");
|
||
use codec::tables::layer::Layer as DocLayer;
|
||
// A layer needs a real handle or it is dropped on a DWG save
|
||
// (the format is handle-based; issue #67).
|
||
let mut dl = DocLayer::new(&new_name);
|
||
// `allocate_handle` advances the seed so the layer gets a
|
||
// unique handle; the non-advancing `next_handle` getter hands
|
||
// out the same value twice and the later object overwrites it.
|
||
dl.handle = self.tabs[i].scene.document.allocate_handle();
|
||
let _ = self.tabs[i].scene.document.layers.add(dl);
|
||
self.tabs[i].dirty = true;
|
||
let doc_layers = self.tabs[i].scene.document.layers.clone();
|
||
let vp_info = self.tabs[i].scene.viewport_list();
|
||
self.tabs[i]
|
||
.layers
|
||
.sync_with_viewports(&doc_layers, vp_info);
|
||
let new_idx = self.tabs[i]
|
||
.layers
|
||
.layers
|
||
.iter()
|
||
.position(|l| l.name == new_name);
|
||
if let Some(idx) = new_idx {
|
||
self.tabs[i].layers.selected = Some(idx);
|
||
self.tabs[i].layers.selected_multi = vec![idx];
|
||
self.tabs[i].layers.editing = Some(idx);
|
||
self.tabs[i].layers.edit_buf = new_name.clone();
|
||
}
|
||
self.sync_ribbon_layers();
|
||
// With alphabetical ordering (#270) the new layer can land
|
||
// anywhere in a long list; scroll its row into view so the
|
||
// rename prompt is visible (#271). Offset is layout-independent
|
||
// (idx × fixed row height), so it holds even before the freshly
|
||
// added row is measured.
|
||
match new_idx {
|
||
Some(idx) => iced::widget::operation::scroll_to(
|
||
iced::advanced::widget::Id::new(
|
||
crate::ui::window::layers::LAYER_TABLE_SCROLL_ID,
|
||
),
|
||
iced::widget::scrollable::AbsoluteOffset {
|
||
x: 0.0,
|
||
y: idx as f32 * crate::ui::ROW_H,
|
||
},
|
||
),
|
||
None => Task::none(),
|
||
}
|
||
}
|
||
|
||
pub(super) fn on_layer_delete(&mut self) -> Task<Message> {
|
||
let i = self.active_tab;
|
||
// Every selected layer (multi-select), or the anchor if none.
|
||
let mut names = self.selected_layer_names(i);
|
||
if names.is_empty() {
|
||
return Task::none();
|
||
}
|
||
// Layer "0" and the current layer can't be deleted — drop them
|
||
// from the batch and note it.
|
||
let current = self.tabs[i].scene.document.header.current_layer_name.clone();
|
||
let before = names.len();
|
||
names.retain(|n| n != "0" && *n != current);
|
||
if names.len() < before {
|
||
self.command_line
|
||
.push_info(crate::t!("Layer \"0\" and the current layer can't be deleted — skipped.").as_ref());
|
||
}
|
||
if names.is_empty() {
|
||
return Task::none();
|
||
}
|
||
// Total objects across the layers. Empty → delete straight away;
|
||
// non-empty → warn first (deleting also removes those objects).
|
||
let count = self.tabs[i]
|
||
.scene
|
||
.document
|
||
.entities()
|
||
.filter(|e| names.contains(&e.common().layer))
|
||
.count();
|
||
if count == 0 {
|
||
self.push_undo_snapshot(i, "LAYER DELETE");
|
||
for name in &names {
|
||
self.tabs[i].scene.document.layers.remove(name);
|
||
}
|
||
self.tabs[i].dirty = true;
|
||
self.sync_layer_panel(i);
|
||
} else {
|
||
self.layer_delete_pending = Some((names, count));
|
||
self.active_modal = Some(crate::app::ModalKind::LayerDeleteWarning);
|
||
self.modal_offset = iced::Vector::ZERO;
|
||
self.modal_resize = iced::Vector::ZERO;
|
||
}
|
||
Task::none()
|
||
}
|
||
|
||
/// User confirmed deleting non-empty layer(s): erase every object on them,
|
||
/// then remove the layer records.
|
||
pub(super) fn on_layer_delete_confirm(&mut self) -> Task<Message> {
|
||
let i = self.active_tab;
|
||
self.active_modal = None;
|
||
self.reset_modal_geometry();
|
||
let Some((names, _)) = self.layer_delete_pending.take() else {
|
||
return Task::none();
|
||
};
|
||
self.push_undo_snapshot(i, "LAYER DELETE");
|
||
let handles: Vec<codec::Handle> = self.tabs[i]
|
||
.scene
|
||
.document
|
||
.entities()
|
||
.filter(|e| names.contains(&e.common().layer))
|
||
.map(|e| e.common().handle)
|
||
.collect();
|
||
// Remove the layer records first so the lock guard in `erase_entities`
|
||
// (which looks the layer up by name) can't block the objects.
|
||
for name in &names {
|
||
self.tabs[i].scene.document.layers.remove(name);
|
||
}
|
||
self.tabs[i].scene.erase_entities(&handles);
|
||
self.tabs[i].dirty = true;
|
||
self.sync_layer_panel(i);
|
||
self.refresh_properties();
|
||
Task::none()
|
||
}
|
||
|
||
/// Layers a Layer-manager row toggle (visibility / lock / freeze /
|
||
/// transparency) should affect: the whole multi-selection when the clicked
|
||
/// row is part of it, otherwise just the clicked row — so those toggles work
|
||
/// in bulk like the color / linetype / lineweight edits already do (#236).
|
||
pub(super) fn layer_row_action_targets(&self, i: usize, idx: usize) -> Vec<String> {
|
||
let Some(clicked) = self.tabs[i].layers.layers.get(idx).map(|l| l.name.clone()) else {
|
||
return Vec::new();
|
||
};
|
||
let selected = self.selected_layer_names(i);
|
||
if selected.iter().any(|n| n == &clicked) {
|
||
selected
|
||
} else {
|
||
vec![clicked]
|
||
}
|
||
}
|
||
|
||
/// Layer names for the current Layer-manager selection — every row in the
|
||
/// multi-selection, or the anchor row when the multi-set is empty. Bulk
|
||
/// property edits and deletion act on these.
|
||
pub(super) fn selected_layer_names(&self, i: usize) -> Vec<String> {
|
||
let panel = &self.tabs[i].layers;
|
||
let idxs: Vec<usize> = if panel.selected_multi.is_empty() {
|
||
panel.selected.into_iter().collect()
|
||
} else {
|
||
panel.selected_multi.clone()
|
||
};
|
||
idxs.iter()
|
||
.filter_map(|&x| panel.layers.get(x).map(|l| l.name.clone()))
|
||
.collect()
|
||
}
|
||
|
||
/// Rebuild the Layer-manager panel + ribbon after a layer-table change.
|
||
fn sync_layer_panel(&mut self, i: usize) {
|
||
let doc_layers = self.tabs[i].scene.document.layers.clone();
|
||
let vp_info = self.tabs[i].scene.viewport_list();
|
||
self.tabs[i].layers.sync_with_viewports(&doc_layers, vp_info);
|
||
self.tabs[i].layers.selected = None;
|
||
self.tabs[i].layers.selected_multi.clear();
|
||
self.sync_ribbon_layers();
|
||
}
|
||
|
||
pub(super) fn on_layer_set_current(&mut self) -> Task<Message> {
|
||
let i = self.active_tab;
|
||
if let Some(idx) = self.tabs[i].layers.selected {
|
||
if let Some(layer) = self.tabs[i].layers.layers.get(idx) {
|
||
let name = layer.name.clone();
|
||
if name == self.tabs[i].layers.current_layer {
|
||
return Task::none();
|
||
}
|
||
// Mirror the change into the document header (CLAYER) too,
|
||
// not just the per-tab default. Otherwise the no-selection
|
||
// ribbon refresh (e.g. after Esc) re-reads the stale header
|
||
// layer and the dropdown snaps back to it. See #93.
|
||
let handle = self.tabs[i]
|
||
.scene
|
||
.document
|
||
.layers
|
||
.get(&name)
|
||
.map(|l| l.handle)
|
||
.unwrap_or(codec::types::Handle::NULL);
|
||
self.tabs[i].scene.document.header.current_layer_name = name.clone();
|
||
self.tabs[i].scene.document.header.current_layer_handle = handle;
|
||
self.tabs[i].active_layer = name.clone();
|
||
self.tabs[i].layers.current_layer = name.clone();
|
||
self.tabs[i].dirty = true;
|
||
self.ribbon.active_layer = name;
|
||
}
|
||
}
|
||
Task::none()
|
||
}
|
||
|
||
pub(super) fn on_layer_rename_commit(&mut self) -> Task<Message> {
|
||
let i = self.active_tab;
|
||
let editing_idx = self.tabs[i].layers.editing.take();
|
||
if let Some(idx) = editing_idx {
|
||
let new_name = self.tabs[i].layers.edit_buf.trim().to_string();
|
||
let old_name = self.tabs[i]
|
||
.layers
|
||
.layers
|
||
.get(idx)
|
||
.map(|l| l.name.clone())
|
||
.unwrap_or_default();
|
||
if !new_name.is_empty() && new_name != old_name {
|
||
self.push_undo_snapshot(i, "LAYER RENAME");
|
||
if !self.tabs[i].rename_layer(&old_name, &new_name) {
|
||
self.discard_last_undo_entry(i);
|
||
}
|
||
}
|
||
self.tabs[i].layers.edit_buf.clear();
|
||
self.refresh_layer_panel();
|
||
}
|
||
Task::none()
|
||
}
|
||
|
||
pub(super) fn on_grip_menu_pick(&mut self, idx: usize) -> Task<Message> {
|
||
let i = self.active_tab;
|
||
let Some(popup) = self.grip_popup.take() else {
|
||
return Task::none();
|
||
};
|
||
if self.reject_locked_edit(i, popup.handle) {
|
||
return Task::none();
|
||
}
|
||
self.grip_hover = None;
|
||
let Some(item) = popup.items.get(idx).cloned() else {
|
||
return Task::none();
|
||
};
|
||
use crate::entities::traits::EntityTypeOps;
|
||
use crate::scene::model::object::GripMenuAction;
|
||
if matches!(
|
||
item.action,
|
||
GripMenuAction::Stretch
|
||
| GripMenuAction::RectangleResize
|
||
| GripMenuAction::MoveWithText
|
||
| GripMenuAction::MoveWithDimLine
|
||
| GripMenuAction::MoveWithLeader
|
||
| GripMenuAction::MoveIndependent
|
||
) {
|
||
let is_dimension = matches!(
|
||
self.tabs[i].scene.document.get_entity(popup.handle),
|
||
Some(codec::EntityType::Dimension(_))
|
||
);
|
||
if is_dimension {
|
||
let movement = match item.action {
|
||
GripMenuAction::MoveWithDimLine => Some("Keep dim line with text"),
|
||
GripMenuAction::MoveWithLeader => Some("Move text, add leader"),
|
||
GripMenuAction::MoveIndependent => Some("Move text, no leader"),
|
||
_ => None,
|
||
};
|
||
if let Some(movement) = movement {
|
||
crate::entities::dim_override::set_property(
|
||
&mut self.tabs[i].scene.document,
|
||
popup.handle,
|
||
"dim_text_movement",
|
||
movement,
|
||
);
|
||
self.tabs[i].dirty = true;
|
||
self.tabs[i].scene.bump_entities(&[(
|
||
popup.handle,
|
||
crate::scene::ChangeKind::Modified,
|
||
)]);
|
||
}
|
||
}
|
||
// Stretch / Move = grab this grip. Engage it so the next
|
||
// click places it (click-move-click) — same as picking the
|
||
// grip directly in the viewport. Without this the menu just
|
||
// closed and the grip never became hot (issue #48).
|
||
if let Some((_, g)) = self.tabs[i]
|
||
.selected_grip_handles
|
||
.iter()
|
||
.zip(self.tabs[i].selected_grips.iter())
|
||
.find(|(owner, g)| **owner == popup.handle && g.id == popup.grip_id)
|
||
{
|
||
// Only multileaders use the whole-object grip.
|
||
let is_multileader = matches!(
|
||
self.tabs[i].scene.document.get_entity(popup.handle),
|
||
Some(codec::EntityType::MultiLeader(_))
|
||
);
|
||
let (grip_id, is_translate) =
|
||
if matches!(item.action, GripMenuAction::MoveWithLeader)
|
||
&& is_multileader
|
||
{
|
||
(crate::entities::multileader::MOVE_ALL_GRIP, true)
|
||
} else {
|
||
(
|
||
popup.grip_id,
|
||
matches!(item.action, GripMenuAction::MoveWithText)
|
||
|| (!is_dimension && g.is_midpoint),
|
||
)
|
||
};
|
||
if matches!(item.action, GripMenuAction::RectangleResize) {
|
||
let rectangle = self.tabs[i]
|
||
.scene
|
||
.document
|
||
.get_entity(popup.handle)
|
||
.and_then(|entity| {
|
||
let AcadEntityType::LwPolyline(polyline) = entity else {
|
||
return None;
|
||
};
|
||
let (frame, plane) =
|
||
crate::entities::lwpolyline::rectangle_frame(polyline)?;
|
||
let opposite = polyline
|
||
.vertices
|
||
.get((popup.grip_id + 2) % 4)?
|
||
.location;
|
||
Some((
|
||
glam::DVec3::from_array(
|
||
plane.point_at([opposite.x, opposite.y]),
|
||
),
|
||
glam::DVec3::from_array(plane.vector_at(frame.width_axis))
|
||
.try_normalize()?,
|
||
glam::DVec3::from_array(plane.vector_at(frame.height_axis))
|
||
.try_normalize()?,
|
||
))
|
||
});
|
||
if let Some((opposite, width_axis, height_axis)) = rectangle {
|
||
if self.grip_originals.is_empty() {
|
||
self.grip_originals = self.tabs[i]
|
||
.scene
|
||
.document
|
||
.get_entity(popup.handle)
|
||
.cloned()
|
||
.map(|entity| vec![(popup.handle, entity)])
|
||
.unwrap_or_default();
|
||
}
|
||
self.tabs[i].active_grip = Some(GripEdit::rectangle_resize(
|
||
popup.handle,
|
||
popup.grip_id,
|
||
g.world,
|
||
opposite,
|
||
width_axis,
|
||
height_axis,
|
||
));
|
||
self.sync_dyn_fields();
|
||
}
|
||
} else {
|
||
self.tabs[i].active_grip = Some(GripEdit::single(
|
||
popup.handle,
|
||
grip_id,
|
||
is_translate,
|
||
g.world,
|
||
));
|
||
}
|
||
}
|
||
return Task::none();
|
||
}
|
||
// Actions that need a follow-up number stash a pending
|
||
// state + prompt; the next typed value drives
|
||
// `apply_grip_menu_value`.
|
||
let prompt = self.tabs[i]
|
||
.scene
|
||
.document
|
||
.get_entity(popup.handle)
|
||
.and_then(|e| e.grip_menu_value_prompt(popup.grip_id, item.action));
|
||
if let Some(label) = prompt {
|
||
self.grip_pending = Some(crate::app::GripPendingValue {
|
||
handle: popup.handle,
|
||
grip_id: popup.grip_id,
|
||
action: item.action,
|
||
label,
|
||
});
|
||
if matches!(
|
||
item.action,
|
||
GripMenuAction::Lengthen
|
||
| GripMenuAction::Radius
|
||
| GripMenuAction::ArcLength
|
||
| GripMenuAction::RectangleWidth
|
||
| GripMenuAction::RectangleHeight
|
||
| GripMenuAction::MoveParallel
|
||
) {
|
||
if let Some((_, grip)) = self.tabs[i]
|
||
.selected_grip_handles
|
||
.iter()
|
||
.zip(self.tabs[i].selected_grips.iter())
|
||
.find(|(owner, grip)| {
|
||
**owner == popup.handle && grip.id == popup.grip_id
|
||
})
|
||
{
|
||
if self.grip_originals.is_empty() {
|
||
self.grip_originals = self.tabs[i]
|
||
.scene
|
||
.document
|
||
.get_entity(popup.handle)
|
||
.cloned()
|
||
.map(|entity| vec![(popup.handle, entity)])
|
||
.unwrap_or_default();
|
||
}
|
||
self.tabs[i].active_grip = Some(match item.action {
|
||
GripMenuAction::Radius => GripEdit::radius(
|
||
popup.handle,
|
||
popup.grip_id,
|
||
grip.world,
|
||
),
|
||
GripMenuAction::ArcLength => GripEdit::arc_length(
|
||
popup.handle,
|
||
popup.grip_id,
|
||
grip.world,
|
||
),
|
||
GripMenuAction::RectangleWidth => GripEdit::rectangle_width(
|
||
popup.handle,
|
||
popup.grip_id,
|
||
grip.world,
|
||
),
|
||
GripMenuAction::RectangleHeight => GripEdit::rectangle_height(
|
||
popup.handle,
|
||
popup.grip_id,
|
||
grip.world,
|
||
),
|
||
GripMenuAction::MoveParallel => GripEdit::move_parallel(
|
||
popup.handle,
|
||
popup.grip_id,
|
||
grip.world,
|
||
),
|
||
_ => GripEdit::lengthen(
|
||
popup.handle,
|
||
popup.grip_id,
|
||
grip.world,
|
||
),
|
||
});
|
||
}
|
||
// Popup actions do not pass through the normal grip
|
||
// press handler, which is where dynamic fields are
|
||
// usually seeded. Build the value field
|
||
// immediately so it is visible before the next mouse
|
||
// move (and so keyboard input has a field to target).
|
||
self.sync_dyn_fields();
|
||
if matches!(item.action, GripMenuAction::Radius) {
|
||
self.command_line.push_info(
|
||
crate::t!("Specify point or enter radius:").as_ref(),
|
||
);
|
||
} else if matches!(item.action, GripMenuAction::ArcLength) {
|
||
self.command_line.push_info(
|
||
crate::t!("Specify point or enter arc length:").as_ref(),
|
||
);
|
||
} else if matches!(item.action, GripMenuAction::RectangleWidth) {
|
||
self.command_line.push_info("Specify point or enter width:");
|
||
} else if matches!(item.action, GripMenuAction::RectangleHeight) {
|
||
self.command_line.push_info("Specify point or enter height:");
|
||
} else if matches!(item.action, GripMenuAction::MoveParallel) {
|
||
self.command_line.push_info("Specify point or enter parallel offset:");
|
||
} else {
|
||
self.command_line.push_info(
|
||
crate::t!("Specify point or enter distance:").as_ref(),
|
||
);
|
||
}
|
||
} else {
|
||
self.command_line.push_info(crate::tf!("{label}:").as_ref());
|
||
}
|
||
return self.focus_cmd_input();
|
||
}
|
||
// Break at vertex replaces the entity with the split pieces —
|
||
// structural, so it can't ride the in-place apply below.
|
||
if matches!(item.action, GripMenuAction::BreakVertex) {
|
||
let pieces = match self.tabs[i].scene.document.get_entity(popup.handle) {
|
||
Some(codec::EntityType::LwPolyline(p)) => {
|
||
crate::entities::lwpolyline::break_at_vertex(p, popup.grip_id)
|
||
}
|
||
_ => None,
|
||
};
|
||
match pieces {
|
||
Some(pieces) => {
|
||
self.push_undo_snapshot(i, "BREAK");
|
||
self.tabs[i].scene.erase_entities(&[popup.handle]);
|
||
for e in pieces {
|
||
self.tabs[i].scene.add_entity(e);
|
||
}
|
||
self.tabs[i].dirty = true;
|
||
self.refresh_selected_grips();
|
||
self.refresh_properties();
|
||
self.command_line.push_output(crate::t!("Polyline broken at vertex.").as_ref());
|
||
}
|
||
None => self
|
||
.command_line
|
||
.push_error(crate::t!("Cannot break at this vertex.").as_ref()),
|
||
}
|
||
return Task::none();
|
||
}
|
||
// Polyline Add Vertex is an interactive placement, not an
|
||
// immediate midpoint edit. Seed the undo snapshot before the
|
||
// provisional vertex exists, then engage its new grip so the
|
||
// regular snap/ortho/polar preview follows the cursor. One
|
||
// click commits the whole append+move; Escape restores the
|
||
// original entity.
|
||
if matches!(item.action, GripMenuAction::AddVertex) {
|
||
let placement = self.tabs[i]
|
||
.scene
|
||
.document
|
||
.get_entity(popup.handle)
|
||
.cloned()
|
||
.and_then(|original| {
|
||
polyline_add_vertex_target(&original, popup.grip_id)
|
||
.map(|(new_gid, old_len)| (original, new_gid, old_len))
|
||
});
|
||
if let Some((original, new_gid, old_len)) = placement {
|
||
let dirty_before = self.tabs[i].dirty;
|
||
if let Some(entity) =
|
||
self.tabs[i].scene.document.get_entity_mut(popup.handle)
|
||
{
|
||
entity.apply_grip_menu(popup.grip_id, item.action);
|
||
}
|
||
let inserted = self.tabs[i]
|
||
.scene
|
||
.document
|
||
.get_entity(popup.handle)
|
||
.and_then(polyline_vertex_count)
|
||
.is_some_and(|len| len == old_len + 1);
|
||
if !inserted {
|
||
if let Some(entity) =
|
||
self.tabs[i].scene.document.get_entity_mut(popup.handle)
|
||
{
|
||
*entity = original;
|
||
}
|
||
self.tabs[i].scene.bump_entities(&[
|
||
(popup.handle, crate::scene::ChangeKind::Modified),
|
||
]);
|
||
self.tabs[i].dirty = dirty_before;
|
||
self.refresh_selected_grips();
|
||
self.refresh_properties();
|
||
self.command_line.push_error(crate::t!("Cannot add a vertex here.").as_ref());
|
||
return Task::none();
|
||
}
|
||
self.tabs[i]
|
||
.scene
|
||
.bump_entities(&[(popup.handle, crate::scene::ChangeKind::Modified)]);
|
||
self.tabs[i].dirty = true;
|
||
self.refresh_selected_grips();
|
||
self.refresh_properties();
|
||
let grip_world = self.tabs[i]
|
||
.selected_grip_handles
|
||
.iter()
|
||
.zip(self.tabs[i].selected_grips.iter())
|
||
.find(|(owner, grip)| {
|
||
**owner == popup.handle && grip.id == new_gid
|
||
})
|
||
.map(|(_, grip)| grip.world);
|
||
if let Some(grip_world) = grip_world {
|
||
self.grip_originals = vec![(popup.handle, original)];
|
||
self.grip_dirty_before = Some(dirty_before);
|
||
self.tabs[i].active_grip = Some(GripEdit::single(
|
||
popup.handle,
|
||
new_gid,
|
||
false,
|
||
grip_world,
|
||
));
|
||
self.command_line
|
||
.push_info(crate::t!("Specify new vertex location:").as_ref());
|
||
} else {
|
||
if let Some(entity) =
|
||
self.tabs[i].scene.document.get_entity_mut(popup.handle)
|
||
{
|
||
*entity = original;
|
||
}
|
||
self.tabs[i].scene.bump_entities(&[
|
||
(popup.handle, crate::scene::ChangeKind::Modified),
|
||
]);
|
||
self.tabs[i].dirty = dirty_before;
|
||
self.refresh_selected_grips();
|
||
self.refresh_properties();
|
||
self.command_line
|
||
.push_error(crate::t!("Cannot place the new vertex.").as_ref());
|
||
}
|
||
return Task::none();
|
||
}
|
||
}
|
||
// One-shot action — apply immediately.
|
||
let unchanged = item.label.starts_with('✓')
|
||
|| self.tabs[i]
|
||
.scene
|
||
.document
|
||
.get_entity(popup.handle)
|
||
.is_some_and(|entity| match (item.action, entity) {
|
||
(GripMenuAction::ShowFit, codec::EntityType::Spline(spline)) => {
|
||
crate::entities::spline::shows_fit_points(spline)
|
||
}
|
||
(
|
||
GripMenuAction::ShowControlVertices,
|
||
codec::EntityType::Spline(spline),
|
||
) => {
|
||
crate::entities::spline::shows_control_vertices(spline)
|
||
}
|
||
_ => false,
|
||
});
|
||
if unchanged {
|
||
return Task::none();
|
||
}
|
||
self.push_undo_snapshot(i, item.label);
|
||
// For Add Leader, the new arrow becomes the last grip; remember
|
||
// its id so we can grab it for placement right after.
|
||
let add_leader_gid = if matches!(item.action, GripMenuAction::AddLeader) {
|
||
self.tabs[i]
|
||
.scene
|
||
.document
|
||
.get_entity(popup.handle)
|
||
.and_then(|e| match e {
|
||
codec::EntityType::MultiLeader(ml) => Some(
|
||
ml.context
|
||
.leader_roots
|
||
.iter()
|
||
.flat_map(|r| r.lines.iter())
|
||
.map(|l| l.points.len())
|
||
.sum::<usize>(),
|
||
),
|
||
_ => None,
|
||
})
|
||
} else {
|
||
None
|
||
};
|
||
if matches!(item.action, GripMenuAction::AboveDimLine) {
|
||
crate::entities::dim_override::set_property(
|
||
&mut self.tabs[i].scene.document,
|
||
popup.handle,
|
||
"dim_text_pos_vert",
|
||
"Above",
|
||
);
|
||
}
|
||
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(popup.handle) {
|
||
entity.apply_grip_menu(popup.grip_id, item.action);
|
||
}
|
||
// Menu actions reshape dimensions too — drop a stale baked *D
|
||
// block so the edit is visible (no-op for non-dims). (#398)
|
||
self.tabs[i]
|
||
.scene
|
||
.invalidate_dim_block_recorded(popup.handle);
|
||
self.tabs[i]
|
||
.scene
|
||
.bump_entities(&[(popup.handle, crate::scene::ChangeKind::Modified)]);
|
||
self.tabs[i].dirty = true;
|
||
self.refresh_selected_grips();
|
||
self.refresh_properties();
|
||
// Grab the new arrow so it follows the cursor (click places it,
|
||
// Esc removes it).
|
||
if let Some(new_gid) = add_leader_gid {
|
||
if let Some((_, g)) = self.tabs[i]
|
||
.selected_grip_handles
|
||
.iter()
|
||
.zip(self.tabs[i].selected_grips.iter())
|
||
.find(|(owner, g)| **owner == popup.handle && g.id == new_gid)
|
||
{
|
||
self.tabs[i].active_grip = Some(GripEdit::single(
|
||
popup.handle,
|
||
new_gid,
|
||
false,
|
||
g.world,
|
||
));
|
||
self.grip_add_provisional = Some((popup.handle, new_gid));
|
||
}
|
||
}
|
||
// Convert to Arc enters placement: the segment grip goes hot
|
||
// in Absolute mode, so the arc re-fits through the cursor as
|
||
// it moves and the next click seats it (#339).
|
||
if matches!(item.action, GripMenuAction::ConvertToArc) {
|
||
if let Some((_, g)) = self.tabs[i]
|
||
.selected_grip_handles
|
||
.iter()
|
||
.zip(self.tabs[i].selected_grips.iter())
|
||
.find(|(owner, g)| **owner == popup.handle && g.id == popup.grip_id)
|
||
{
|
||
self.tabs[i].active_grip = Some(GripEdit::single(
|
||
popup.handle,
|
||
popup.grip_id,
|
||
false,
|
||
g.world,
|
||
));
|
||
}
|
||
}
|
||
Task::none()
|
||
}
|
||
|
||
pub(super) fn on_paste_shortcut(&mut self) -> Task<Message> {
|
||
if self.mtext_editor.is_some() {
|
||
// Web reads via the browser's async Clipboard API (iced's
|
||
// sync clipboard read returns nothing there); native uses
|
||
// iced's clipboard.
|
||
#[cfg(target_arch = "wasm32")]
|
||
return Task::perform(
|
||
crate::sys::read_clipboard_text(),
|
||
Message::MTextPasteClip,
|
||
);
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
return iced::clipboard::read_text().map(|result| {
|
||
Message::MTextPasteClip(result.ok().map(|text| (*text).clone()))
|
||
});
|
||
}
|
||
if self.text_inline.is_some() {
|
||
// Web: the iced text_input can't reach the async clipboard,
|
||
// so paste it ourselves. Native: the focused text_input
|
||
// already handled Ctrl+V — doing it here would duplicate.
|
||
#[cfg(target_arch = "wasm32")]
|
||
return Task::perform(
|
||
crate::sys::read_clipboard_text(),
|
||
Message::TextInlinePasteClip,
|
||
);
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
return Task::none();
|
||
}
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
{
|
||
iced::advanced::widget::operate(PasteFocusProbe::default())
|
||
.map(Message::PasteShortcutResolved)
|
||
}
|
||
#[cfg(target_arch = "wasm32")]
|
||
{
|
||
if self.clipboard.is_empty() {
|
||
self.read_system_clipboard_for_paste()
|
||
} else {
|
||
Task::done(Message::Command("PASTECLIP".to_string()))
|
||
}
|
||
}
|
||
}
|
||
|
||
pub(in crate::app) fn read_system_clipboard_for_paste(&self) -> Task<Message> {
|
||
#[cfg(target_arch = "wasm32")]
|
||
{
|
||
Task::perform(crate::sys::read_clipboard_text(), |text| {
|
||
Message::SystemClipboardPaste(match text {
|
||
Some(text) if !text.is_empty() => {
|
||
crate::app::SystemClipboardText::Text(text)
|
||
}
|
||
_ => crate::app::SystemClipboardText::EmptyOrUnsupported,
|
||
})
|
||
})
|
||
}
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
{
|
||
iced::clipboard::read_text().map(|result| {
|
||
use crate::app::SystemClipboardText as Text;
|
||
use iced::clipboard::Error;
|
||
|
||
let result = match result {
|
||
Ok(text) if !text.is_empty() => Text::Text((*text).clone()),
|
||
Ok(_) | Err(Error::ContentNotAvailable) => Text::EmptyOrUnsupported,
|
||
Err(Error::ClipboardUnavailable) | Err(Error::Unknown { .. }) => {
|
||
Text::Unavailable
|
||
}
|
||
Err(Error::ClipboardOccupied) => Text::Occupied,
|
||
Err(Error::ConversionFailure) => Text::ConversionFailed,
|
||
};
|
||
Message::SystemClipboardPaste(result)
|
||
})
|
||
}
|
||
}
|
||
|
||
pub(super) fn on_qselect_open(&mut self) -> Task<Message> {
|
||
let i = self.active_tab;
|
||
self.tabs[i].scene.selection.borrow_mut().context_menu = None;
|
||
let remembered = self.qselect_settings.clone();
|
||
let scope = remembered.as_ref().map_or(
|
||
crate::app::QSelectScope::CurrentSpace,
|
||
|settings| settings.scope,
|
||
);
|
||
let available_types = self.tabs[i].scene.qselect_entity_type_names(scope);
|
||
let type_filter = if let Some(settings) = remembered.as_ref() {
|
||
settings
|
||
.type_filter
|
||
.as_ref()
|
||
.filter(|selected| available_types.iter().any(|item| item == *selected))
|
||
.cloned()
|
||
} else {
|
||
self.tabs[i]
|
||
.scene
|
||
.selected
|
||
.iter()
|
||
.next()
|
||
.and_then(|handle| self.tabs[i].scene.document.get_entity(*handle))
|
||
.map(crate::entities::traits::entity_type_name)
|
||
.map(str::to_string)
|
||
};
|
||
let available_properties = self.tabs[i]
|
||
.scene
|
||
.qselect_properties(type_filter.as_deref(), scope);
|
||
let candidate_count = self.tabs[i].scene.qselect_candidate_count(scope);
|
||
let property = remembered
|
||
.as_ref()
|
||
.and_then(|settings| settings.property_field.as_ref())
|
||
.and_then(|field| {
|
||
available_properties
|
||
.iter()
|
||
.find(|property| property.field == *field)
|
||
.cloned()
|
||
});
|
||
let mut operator = remembered
|
||
.as_ref()
|
||
.map_or(crate::app::QSelectOp::Eq, |settings| settings.operator);
|
||
if matches!(operator, crate::app::QSelectOp::Gt | crate::app::QSelectOp::Lt)
|
||
&& !property.as_ref().is_some_and(|property| {
|
||
matches!(property.editor, crate::app::QSelectValueEditor::Number)
|
||
})
|
||
{
|
||
operator = crate::app::QSelectOp::Eq;
|
||
}
|
||
let value = remembered.as_ref().map_or_else(String::new, |settings| {
|
||
if settings.property_field.is_none() || property.is_some() {
|
||
settings.value.clone()
|
||
} else {
|
||
String::new()
|
||
}
|
||
});
|
||
let mode = remembered
|
||
.as_ref()
|
||
.map_or(crate::app::QSelectMode::Include, |settings| settings.mode);
|
||
let append = matches!(scope, crate::app::QSelectScope::CurrentSpace)
|
||
&& remembered.as_ref().is_some_and(|settings| settings.append);
|
||
self.qselect = Some(crate::app::QSelectState {
|
||
scope,
|
||
available_types,
|
||
available_properties,
|
||
candidate_count,
|
||
type_filter,
|
||
property,
|
||
operator,
|
||
value,
|
||
mode,
|
||
append,
|
||
error: None,
|
||
});
|
||
self.reset_modal_geometry();
|
||
Task::none()
|
||
}
|
||
|
||
pub(super) fn on_ribbon_layer_changed(&mut self, layer: String) -> Task<Message> {
|
||
let i = self.active_tab;
|
||
self.ribbon.close_dropdown();
|
||
let handles = self.property_target_handles(i);
|
||
if handles.is_empty() {
|
||
if self.has_property_selection(i) {
|
||
return Task::none();
|
||
}
|
||
// No selection — change the creation default. Persist
|
||
// into the tab's header (CLAYER) so it survives a tab
|
||
// switch and rides the next save. #21.
|
||
let handle = self.tabs[i]
|
||
.scene
|
||
.document
|
||
.layers
|
||
.get(&layer)
|
||
.map(|l| l.handle)
|
||
.unwrap_or(codec::types::Handle::NULL);
|
||
self.tabs[i].scene.document.header.current_layer_name = layer.clone();
|
||
self.tabs[i].scene.document.header.current_layer_handle = handle;
|
||
self.tabs[i].active_layer = layer.clone();
|
||
self.tabs[i].layers.current_layer = layer.clone();
|
||
self.tabs[i].dirty = true;
|
||
self.ribbon.active_layer = layer;
|
||
} else {
|
||
// Apply to the selection; leave the creation default alone
|
||
// ("Make current" is a separate action).
|
||
// Layer drives by-layer colour/linetype/lineweight, which are
|
||
// baked into the cached wire geometry — re-tessellate so the
|
||
// change shows immediately (issue #231 class).
|
||
self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
|
||
if let Some(entity) =
|
||
app.tabs[i].scene.document.get_entity_mut(handle)
|
||
{
|
||
crate::scene::view::dispatch::apply_common_prop(entity, "layer", &layer);
|
||
}
|
||
});
|
||
self.ribbon.active_layer = layer;
|
||
}
|
||
Task::none()
|
||
}
|
||
|
||
pub(super) fn on_ribbon_color_changed(&mut self, color: AcadColor) -> Task<Message> {
|
||
let i = self.active_tab;
|
||
self.ribbon.prop_color_palette_open = false;
|
||
self.ribbon.close_dropdown();
|
||
self.note_recent_color(color);
|
||
let handles = self.property_target_handles(i);
|
||
if handles.is_empty() {
|
||
if self.has_property_selection(i) {
|
||
return Task::none();
|
||
}
|
||
// Persist the new default into the tab's header so it
|
||
// round-trips through tab switches and writes back on
|
||
// save (CECOLOR). #21.
|
||
self.tabs[i].scene.document.header.current_entity_color = color;
|
||
self.tabs[i].dirty = true;
|
||
self.ribbon.active_color = color;
|
||
} else {
|
||
self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
|
||
if let Some(entity) =
|
||
app.tabs[i].scene.document.get_entity_mut(handle)
|
||
{
|
||
crate::scene::view::dispatch::apply_color(entity, color);
|
||
}
|
||
});
|
||
self.ribbon.active_color = color;
|
||
}
|
||
Task::none()
|
||
}
|
||
|
||
pub(super) fn on_ribbon_linetype_changed(&mut self, lt: String) -> Task<Message> {
|
||
let i = self.active_tab;
|
||
self.ribbon.close_dropdown();
|
||
let handles = self.property_target_handles(i);
|
||
if handles.is_empty() {
|
||
if self.has_property_selection(i) {
|
||
return Task::none();
|
||
}
|
||
// Persist into the tab's header (CELTYPE). Resolve to a
|
||
// handle when the name matches a line_types entry so the
|
||
// handle-based lookup stays in sync. #21.
|
||
let handle = self.tabs[i]
|
||
.scene
|
||
.document
|
||
.line_types
|
||
.iter()
|
||
.find(|x| x.name.eq_ignore_ascii_case(<))
|
||
.map(|x| x.handle)
|
||
.unwrap_or(codec::types::Handle::NULL);
|
||
self.tabs[i].scene.document.header.current_linetype_name = lt.clone();
|
||
self.tabs[i].scene.document.header.current_linetype_handle = handle;
|
||
self.tabs[i].dirty = true;
|
||
self.ribbon.active_linetype = lt;
|
||
} else {
|
||
// Linetype is baked into the cached wire geometry —
|
||
// re-tessellate so the dashed/solid look updates immediately
|
||
// (issue #231 class).
|
||
self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
|
||
if let Some(entity) =
|
||
app.tabs[i].scene.document.get_entity_mut(handle)
|
||
{
|
||
crate::scene::view::dispatch::apply_common_prop(entity, "linetype", <);
|
||
}
|
||
});
|
||
self.ribbon.active_linetype = lt;
|
||
}
|
||
Task::none()
|
||
}
|
||
|
||
pub(super) fn on_ribbon_style_changed(&mut self, key: crate::modules::StyleKey, name: String) -> Task<Message> {
|
||
use crate::modules::StyleKey;
|
||
self.ribbon.close_dropdown();
|
||
match key {
|
||
StyleKey::TextStyle => {
|
||
self.ribbon.active_text_style = name.clone();
|
||
let i = self.active_tab;
|
||
let found = self.tabs[i]
|
||
.scene
|
||
.document
|
||
.text_styles
|
||
.iter()
|
||
.find(|s| s.name == name)
|
||
.map(|ts| ts.handle);
|
||
if let Some(h) = found {
|
||
self.tabs[i].scene.document.header.current_text_style_handle = h;
|
||
self.tabs[i].scene.document.header.current_text_style_name = name;
|
||
}
|
||
}
|
||
StyleKey::DimStyle => {
|
||
self.ribbon.active_dim_style = name.clone();
|
||
let i = self.active_tab;
|
||
let found = self.tabs[i]
|
||
.scene
|
||
.document
|
||
.dim_styles
|
||
.get(&name)
|
||
.map(|ds| ds.handle);
|
||
if let Some(h) = found {
|
||
self.tabs[i].scene.document.header.current_dimstyle_handle = h;
|
||
self.tabs[i].scene.document.header.current_dimstyle_name = name;
|
||
}
|
||
}
|
||
StyleKey::MLeaderStyle => {
|
||
let i = self.active_tab;
|
||
|
||
self.ribbon.active_mleader_style = name.clone();
|
||
self.tabs[i].active_mleader_style = name.clone();
|
||
|
||
self.tabs[i]
|
||
.scene
|
||
.document
|
||
.header
|
||
.current_mleader_style_name = name;
|
||
|
||
self.tabs[i].dirty = true;
|
||
}
|
||
StyleKey::TableStyle => {
|
||
self.ribbon.active_table_style = name;
|
||
}
|
||
}
|
||
Task::none()
|
||
}
|
||
|
||
pub(super) fn on_prop_hatch_pattern_changed(&mut self, name: String) -> Task<Message> {
|
||
let i = self.active_tab;
|
||
let handles = self.property_target_handles(i);
|
||
if !handles.is_empty() {
|
||
use crate::scene::model::hatch_patterns;
|
||
if let Some(entry) = hatch_patterns::find(&name) {
|
||
self.push_undo_snapshot(i, "HATCHEDIT");
|
||
for &handle in &handles {
|
||
if let Some(codec::EntityType::Hatch(dxf)) =
|
||
self.tabs[i].scene.document.get_entity_mut(handle)
|
||
{
|
||
let mut pattern = hatch_patterns::build_dxf_pattern(entry);
|
||
// Stored pattern lines are final world-space
|
||
// geometry. Preserve the selected hatch's scale,
|
||
// angle and origin when replacing the catalog
|
||
// pattern.
|
||
crate::entities::hatch::scale_pattern_geometry(
|
||
&mut pattern,
|
||
dxf.pattern_scale,
|
||
);
|
||
crate::entities::hatch::rotate_pattern_geometry(
|
||
&mut pattern,
|
||
dxf.pattern_angle,
|
||
);
|
||
let origin = dxf.pattern_origin();
|
||
crate::entities::hatch::translate_pattern_geometry(&mut pattern, origin.x, origin.y);
|
||
dxf.pattern = pattern;
|
||
dxf.is_solid = matches!(
|
||
entry.gpu,
|
||
crate::scene::model::hatch_model::HatchPattern::Solid
|
||
);
|
||
dxf.pattern_type =
|
||
codec::entities::HatchPatternType::Predefined;
|
||
dxf.gradient_color.enabled = false;
|
||
}
|
||
if let Some(model) = self.tabs[i].scene.hatches.get_mut(&handle) {
|
||
model.pattern = entry.gpu.clone();
|
||
model.name = name.clone();
|
||
}
|
||
}
|
||
self.invalidate_property_targets(i, &handles);
|
||
self.tabs[i].dirty = true;
|
||
self.refresh_properties();
|
||
}
|
||
}
|
||
Task::none()
|
||
}
|
||
|
||
pub(super) fn on_prop_geom_choice_changed(
|
||
&mut self,
|
||
field: &'static str,
|
||
value: String,
|
||
) -> Task<Message> {
|
||
if matches!(field, "dyn_constraint_form" | "dyn_constraint_reference") {
|
||
return self.on_dynamic_dimension_choice(field, &value);
|
||
}
|
||
let i = self.active_tab;
|
||
if field == "ext_pcx_locked" {
|
||
let handles = self.point_cloud_lock_targets(i);
|
||
self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
|
||
if let Some(codec::EntityType::Extended(extended)) =
|
||
app.tabs[i].scene.document.get_entity_mut(handle)
|
||
{
|
||
crate::entities::traits::PropertyEditable::apply_geom_prop(extended, field, &value);
|
||
}
|
||
});
|
||
self.tabs[i].properties.edit_choice_open = false;
|
||
return Task::none();
|
||
}
|
||
let handles = self.property_target_handles(i);
|
||
// The Annotative Yes/No list drives the per-object annotative toggle
|
||
// (MTEXT's own flag, the annotation context for the rest).
|
||
if field == "annotative" {
|
||
let wanted =
|
||
value == crate::t!("Yes").as_ref() || value.eq_ignore_ascii_case("yes");
|
||
let Some(handle) = handles.first().copied() else {
|
||
return Task::none();
|
||
};
|
||
let document = &self.tabs[i].scene.document;
|
||
let (toggle, current) = match document.get_entity(handle) {
|
||
Some(codec::EntityType::MText(text)) => ("is_annotative", text.is_annotative),
|
||
Some(entity) => (
|
||
"annotative_ctx",
|
||
crate::scene::annotative::is_annotative(document, entity)
|
||
|| match entity {
|
||
codec::EntityType::Dimension(dimension) => {
|
||
crate::scene::annotative::dim_style_is_annotative(
|
||
document,
|
||
&dimension.base().style_name,
|
||
)
|
||
}
|
||
_ => false,
|
||
},
|
||
),
|
||
None => return Task::none(),
|
||
};
|
||
if wanted != current {
|
||
return self.update(Message::PropBoolToggle(toggle));
|
||
}
|
||
return Task::none();
|
||
}
|
||
|
||
if !handles.is_empty() {
|
||
if matches!(
|
||
field,
|
||
"spline_method"
|
||
| "knot_param"
|
||
| "cv_frame"
|
||
| "fill_type"
|
||
| "gradient_type"
|
||
| "style"
|
||
| "pattern_type_label"
|
||
) {
|
||
let unchanged = handles.iter().all(|handle| {
|
||
match self.tabs[i].scene.document.get_entity(*handle) {
|
||
Some(codec::EntityType::Spline(spline)) => match field {
|
||
"spline_method" => {
|
||
value
|
||
== if crate::entities::spline::shows_fit_points(spline) {
|
||
"Fit"
|
||
} else {
|
||
"Control Vertices"
|
||
}
|
||
}
|
||
"knot_param" => {
|
||
let current = match spline.knot_parameterization {
|
||
0 => "Chord",
|
||
1 => "Square Root",
|
||
2 => "Uniform",
|
||
_ => "Custom",
|
||
};
|
||
value == current
|
||
}
|
||
"cv_frame" => {
|
||
value
|
||
== if spline.cv_frame_visible {
|
||
"Show"
|
||
} else {
|
||
"Hide"
|
||
}
|
||
}
|
||
_ => false,
|
||
},
|
||
Some(codec::EntityType::Hatch(hatch)) => match field {
|
||
"fill_type" => {
|
||
value
|
||
== if hatch.gradient_color.is_single_color {
|
||
"One color"
|
||
} else {
|
||
"Two color"
|
||
}
|
||
}
|
||
"gradient_type" => {
|
||
let (kind, inverted) =
|
||
crate::scene::model::hatch_model::GradientKind::from_name(
|
||
&hatch.gradient_color.name,
|
||
);
|
||
value == kind.choice_label(inverted)
|
||
}
|
||
"style" => {
|
||
value
|
||
== match hatch.style {
|
||
codec::entities::HatchStyleType::Normal => "Normal",
|
||
codec::entities::HatchStyleType::Outer => "Outer",
|
||
codec::entities::HatchStyleType::Ignore => "Ignore",
|
||
}
|
||
}
|
||
"pattern_type_label" => {
|
||
value
|
||
== match hatch.pattern_type {
|
||
codec::entities::HatchPatternType::Predefined => {
|
||
"Predefined"
|
||
}
|
||
codec::entities::HatchPatternType::UserDefined => {
|
||
"User Defined"
|
||
}
|
||
codec::entities::HatchPatternType::Custom => "Custom",
|
||
}
|
||
}
|
||
_ => false,
|
||
},
|
||
_ => false,
|
||
}
|
||
});
|
||
if unchanged {
|
||
return Task::none();
|
||
}
|
||
}
|
||
// CTB mode has no named plot styles, so the `plot_style` arm
|
||
// below is a no-op early return. Guard here — before the shared
|
||
// snapshot — so the no-op path pushes no undo entry. (No other
|
||
// arm returns early after the snapshot, so nothing else depends
|
||
// on it existing at this point.)
|
||
if field == "plot_style" && self.tabs[i].scene.document.header.plotstyle_mode {
|
||
self.refresh_properties();
|
||
return Task::none();
|
||
}
|
||
if field == "transparency" {
|
||
self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
|
||
if app.tabs[i].scene.is_layer_locked(handle) {
|
||
return;
|
||
}
|
||
if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) {
|
||
crate::scene::view::dispatch::apply_common_prop(
|
||
entity,
|
||
"transparency",
|
||
&value,
|
||
);
|
||
}
|
||
});
|
||
self.tabs[i].properties.edit_choice_open = false;
|
||
return Task::none();
|
||
}
|
||
if field == "material" {
|
||
// Material source: ByLayer / ByBlock clear the handle; a
|
||
// named material sets flag 3 + its handle (resolved here
|
||
// because the update loop holds the document).
|
||
let mat_handle: Option<codec::Handle> = self.tabs[i]
|
||
.scene
|
||
.document
|
||
.objects
|
||
.iter()
|
||
.find_map(|(h, o)| match o {
|
||
codec::objects::ObjectType::Material(m) if m.name == value => {
|
||
Some(*h)
|
||
}
|
||
_ => None,
|
||
});
|
||
self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
|
||
if app.tabs[i].scene.is_layer_locked(handle) {
|
||
return;
|
||
}
|
||
if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle)
|
||
{
|
||
let common = entity.common_mut();
|
||
match value.as_str() {
|
||
"ByLayer" => {
|
||
common.material_flags = 0;
|
||
common.material_handle = None;
|
||
}
|
||
"ByBlock" => {
|
||
common.material_flags = 1;
|
||
common.material_handle = None;
|
||
}
|
||
"Global" => {
|
||
common.material_flags = 2;
|
||
common.material_handle = None;
|
||
}
|
||
_ => {
|
||
if let Some(h) = mat_handle {
|
||
common.material_flags = 3;
|
||
common.material_handle = Some(h);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
self.tabs[i].properties.edit_choice_open = false;
|
||
return Task::none();
|
||
}
|
||
if field == "tol_text_style" {
|
||
use crate::entities::dim_override as dov;
|
||
use codec::xdata::XDataValue;
|
||
let style_handle = self.tabs[i]
|
||
.scene
|
||
.document
|
||
.text_styles
|
||
.iter()
|
||
.find(|entry| entry.name.eq_ignore_ascii_case(&value))
|
||
.map(|entry| entry.handle);
|
||
if let Some(style_handle) = style_handle {
|
||
self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
|
||
if app.tabs[i].scene.is_layer_locked(handle)
|
||
|| !matches!(
|
||
app.tabs[i].scene.document.get_entity(handle),
|
||
Some(codec::EntityType::Tolerance(_))
|
||
)
|
||
{
|
||
return;
|
||
}
|
||
dov::set(
|
||
&mut app.tabs[i].scene.document,
|
||
handle,
|
||
dov::DIMTXSTY,
|
||
Some(XDataValue::Handle(style_handle)),
|
||
);
|
||
});
|
||
} else {
|
||
self.invalidate_property_targets(i, &handles);
|
||
self.refresh_properties();
|
||
}
|
||
self.tabs[i].properties.edit_choice_open = false;
|
||
return Task::none();
|
||
}
|
||
if field.starts_with("dim_") {
|
||
self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
|
||
if app.tabs[i].scene.is_layer_locked(handle) {
|
||
return;
|
||
}
|
||
if matches!(
|
||
app.tabs[i].scene.document.get_entity(handle),
|
||
Some(codec::EntityType::Dimension(_))
|
||
) {
|
||
let applied = crate::entities::dim_override::set_property(
|
||
&mut app.tabs[i].scene.document,
|
||
handle,
|
||
field,
|
||
&value,
|
||
);
|
||
if applied && field == "dim_text_inside" {
|
||
if let Some(codec::EntityType::Dimension(
|
||
codec::entities::Dimension::LargeRadial(dimension),
|
||
)) = app.tabs[i].scene.document.get_entity_mut(handle)
|
||
{
|
||
dimension.base.text_user_positioned = false;
|
||
}
|
||
}
|
||
}
|
||
});
|
||
self.tabs[i].properties.edit_choice_open = false;
|
||
return Task::none();
|
||
}
|
||
if field == "vscale_std" {
|
||
self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
|
||
if matches!(
|
||
app.tabs[i].scene.document.get_entity(handle),
|
||
Some(codec::EntityType::Viewport(_))
|
||
) {
|
||
let _ = app.tabs[i]
|
||
.scene
|
||
.set_viewport_scale_named_for(handle, &value);
|
||
}
|
||
});
|
||
self.tabs[i].properties.edit_choice_open = false;
|
||
return Task::none();
|
||
}
|
||
if field == "vp_ucs_name" {
|
||
// Resolve UCS name → cloned data, then mutate viewports.
|
||
let ucs_data = self.tabs[i]
|
||
.scene
|
||
.document
|
||
.ucss
|
||
.iter()
|
||
.find(|u| u.name == value)
|
||
.cloned();
|
||
if let Some(ucs) = ucs_data {
|
||
self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
|
||
if let Some(codec::EntityType::Viewport(vp)) =
|
||
app.tabs[i].scene.document.get_entity_mut(handle)
|
||
{
|
||
vp.ucs_handle = ucs.handle;
|
||
vp.ucs_origin = ucs.origin.clone();
|
||
vp.ucs_x_axis = ucs.x_axis.clone();
|
||
vp.ucs_y_axis = ucs.y_axis.clone();
|
||
vp.ucs_per_viewport = true;
|
||
}
|
||
});
|
||
} else {
|
||
self.invalidate_property_targets(i, &handles);
|
||
self.refresh_properties();
|
||
}
|
||
self.tabs[i].properties.edit_choice_open = false;
|
||
return Task::none();
|
||
}
|
||
if field == "vp_named_view" {
|
||
// Assign a named view to viewport(s): copy camera parameters.
|
||
let view_data = self.tabs[i]
|
||
.scene
|
||
.document
|
||
.views
|
||
.iter()
|
||
.find(|v| v.name == value)
|
||
.cloned();
|
||
if let Some(view) = view_data {
|
||
self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
|
||
if let Some(codec::EntityType::Viewport(vp)) =
|
||
app.tabs[i].scene.document.get_entity_mut(handle)
|
||
{
|
||
vp.view_target = view.target.clone();
|
||
vp.view_direction = view.direction.clone();
|
||
if view.height > 0.0 {
|
||
vp.view_height = view.height;
|
||
}
|
||
}
|
||
});
|
||
self.tabs[i].scene.camera_generation += 1;
|
||
} else {
|
||
self.invalidate_property_targets(i, &handles);
|
||
self.refresh_properties();
|
||
}
|
||
self.tabs[i].properties.edit_choice_open = false;
|
||
return Task::none();
|
||
}
|
||
if crate::scene::model::solid_history::is_loft_geometry_choice(field) {
|
||
// These choices change the generated body, not just history
|
||
// flags. Use the same transactional rebuild as numeric edits.
|
||
self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
|
||
if app.tabs[i].scene.is_layer_locked(handle) {
|
||
return;
|
||
}
|
||
app.tabs[i]
|
||
.scene
|
||
.apply_solid_history_property(handle, field, &value);
|
||
});
|
||
self.tabs[i].properties.edit_choice_open = false;
|
||
return Task::none();
|
||
}
|
||
if crate::scene::model::solid_history::is_history_choice(field) {
|
||
self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
|
||
if app.tabs[i].scene.is_layer_locked(handle) {
|
||
return;
|
||
}
|
||
app.tabs[i]
|
||
.scene
|
||
.apply_solid_history_choice(handle, field, &value);
|
||
});
|
||
self.tabs[i].properties.edit_choice_open = false;
|
||
return Task::none();
|
||
}
|
||
if crate::scene::model::solid_history::is_surface_property_choice(field) {
|
||
use crate::scene::model::solid_history::{
|
||
PROP_SURFACE_MAINTAIN_ASSOCIATIVITY, PROP_SURFACE_SHOW_ASSOCIATIVITY,
|
||
PROP_SURFACE_WIREFRAME_TYPE,
|
||
};
|
||
self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
|
||
if app.tabs[i].scene.is_layer_locked(handle) {
|
||
return;
|
||
}
|
||
let Some(mut state) = app.tabs[i]
|
||
.scene
|
||
.document
|
||
.get_entity(handle)
|
||
.and_then(|entity| match entity {
|
||
codec::EntityType::Surface(surface) => Some(
|
||
crate::entities::solid3d::surface_property_state(surface),
|
||
),
|
||
_ => None,
|
||
})
|
||
else {
|
||
return;
|
||
};
|
||
match field {
|
||
PROP_SURFACE_WIREFRAME_TYPE => {
|
||
state.isolines = !value.eq_ignore_ascii_case("Isoparms")
|
||
}
|
||
PROP_SURFACE_MAINTAIN_ASSOCIATIVITY => {
|
||
state.maintain_associativity = value.eq_ignore_ascii_case("Yes")
|
||
}
|
||
PROP_SURFACE_SHOW_ASSOCIATIVITY => {
|
||
state.show_associativity = value.eq_ignore_ascii_case("Yes")
|
||
}
|
||
_ => return,
|
||
}
|
||
crate::scene::view::dispatch::set_entity_xdata(
|
||
&mut app.tabs[i].scene.document,
|
||
handle,
|
||
crate::entities::solid3d::SURFACE_PROPERTIES_APP,
|
||
Some(crate::entities::solid3d::surface_property_xdata_values(state)),
|
||
);
|
||
if field == PROP_SURFACE_WIREFRAME_TYPE {
|
||
app.tabs[i].scene.reseed_derived_caches(handle);
|
||
}
|
||
});
|
||
self.tabs[i].properties.edit_choice_open = false;
|
||
return Task::none();
|
||
}
|
||
if field == "tol_dim_style" {
|
||
let style = self.tabs[i]
|
||
.scene
|
||
.document
|
||
.dim_styles
|
||
.iter()
|
||
.find(|entry| entry.name.eq_ignore_ascii_case(&value))
|
||
.map(|entry| (entry.handle, entry.name.clone(), entry.annotative));
|
||
if let Some((style_handle, style_name, annotative)) = style {
|
||
let scale = self.tabs[i].scene.creation_annotation_scale_handle();
|
||
self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
|
||
if app.tabs[i].scene.is_layer_locked(handle) {
|
||
return;
|
||
}
|
||
if let Some(codec::EntityType::Tolerance(tolerance)) =
|
||
app.tabs[i].scene.document.get_entity_mut(handle)
|
||
{
|
||
tolerance.dimension_style_handle = Some(style_handle);
|
||
tolerance.dimension_style_name = style_name.clone();
|
||
} else {
|
||
return;
|
||
}
|
||
crate::scene::annotative::set_entity_annotative(
|
||
&mut app.tabs[i].scene.document,
|
||
handle,
|
||
annotative,
|
||
);
|
||
if annotative {
|
||
if let Some(scale) = scale {
|
||
crate::scene::annotative::create_annotation_context(
|
||
&mut app.tabs[i].scene.document,
|
||
handle,
|
||
scale,
|
||
);
|
||
}
|
||
}
|
||
});
|
||
} else {
|
||
self.invalidate_property_targets(i, &handles);
|
||
self.refresh_properties();
|
||
}
|
||
self.tabs[i].properties.edit_choice_open = false;
|
||
return Task::none();
|
||
}
|
||
if field == "tbl_style_handle" {
|
||
let style_handle = self.tabs[i]
|
||
.scene
|
||
.document
|
||
.objects
|
||
.iter()
|
||
.find_map(|(handle, object)| match object {
|
||
codec::objects::ObjectType::TableStyle(style)
|
||
if style.name.eq_ignore_ascii_case(value.trim()) =>
|
||
{
|
||
Some(*handle)
|
||
}
|
||
_ => None,
|
||
});
|
||
if let Some(style_handle) = style_handle {
|
||
self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
|
||
if let Some(codec::EntityType::Table(table)) =
|
||
app.tabs[i].scene.document.get_entity_mut(handle)
|
||
{
|
||
table.table_style_handle = Some(style_handle);
|
||
// Shared-tail `tbl_` epilogue, now local: drop
|
||
// stale block_record refs so the new style takes
|
||
// effect (same final state as style loop + tail
|
||
// clearing, independent fields).
|
||
table.block_record_handle = None;
|
||
}
|
||
});
|
||
} else {
|
||
// Unresolvable name: preserve the old tail's
|
||
// block_record clearing without pushing a snapshot.
|
||
for &handle in &handles {
|
||
if let Some(codec::EntityType::Table(table)) =
|
||
self.tabs[i].scene.document.get_entity_mut(handle)
|
||
{
|
||
table.block_record_handle = None;
|
||
}
|
||
}
|
||
self.invalidate_property_targets(i, &handles);
|
||
self.refresh_properties();
|
||
}
|
||
self.tabs[i].properties.edit_choice_open = false;
|
||
return Task::none();
|
||
}
|
||
self.push_undo_snapshot(i, "CHPROP");
|
||
|
||
if matches!(
|
||
field,
|
||
"mleader_style"
|
||
| "text_style_handle"
|
||
| "arrowhead_handle"
|
||
| "line_type_handle"
|
||
| "block_content_handle"
|
||
) {
|
||
// Resolve a picked name back to the handle the MLEADER
|
||
// stores. The style/text-style rows keep their existing
|
||
// handle on a failed lookup; the arrowhead/linetype rows
|
||
// take the resolved value directly (None = the default
|
||
// "Closed filled" / "ByBlock" option).
|
||
let doc = &self.tabs[i].scene.document;
|
||
let resolved_mleader_style = (field == "mleader_style")
|
||
.then(|| {
|
||
doc.objects.values().find_map(|object| match object {
|
||
codec::objects::ObjectType::MultiLeaderStyle(style)
|
||
if style.name == value => Some(style.clone()),
|
||
_ => None,
|
||
})
|
||
})
|
||
.flatten();
|
||
let resolved: Option<codec::Handle> = match field {
|
||
"mleader_style" => {
|
||
resolved_mleader_style.as_ref().map(|style| style.handle)
|
||
}
|
||
"text_style_handle" => doc
|
||
.text_styles
|
||
.iter()
|
||
.find(|s| s.name == value)
|
||
.map(|s| s.handle),
|
||
"arrowhead_handle" => {
|
||
if value == "Closed filled" {
|
||
None
|
||
} else {
|
||
doc.block_records
|
||
.iter()
|
||
.find(|b| b.name == value)
|
||
.map(|b| b.handle)
|
||
}
|
||
}
|
||
"line_type_handle" => {
|
||
if value == "ByBlock" {
|
||
None
|
||
} else {
|
||
doc.line_types
|
||
.iter()
|
||
.find(|l| l.name == value)
|
||
.map(|l| l.handle)
|
||
}
|
||
}
|
||
"block_content_handle" => doc
|
||
.block_records
|
||
.iter()
|
||
.find(|block| block.name == value)
|
||
.map(|block| block.handle),
|
||
_ => None,
|
||
};
|
||
for &handle in &handles {
|
||
if self.tabs[i].scene.is_layer_locked(handle) {
|
||
continue;
|
||
}
|
||
let mut style_annotation = None;
|
||
if field == "mleader_style" {
|
||
if let Some(style) = &resolved_mleader_style {
|
||
crate::scene::annotative::apply_mleader_style_to_object(
|
||
&mut self.tabs[i].scene.document,
|
||
handle,
|
||
style,
|
||
);
|
||
style_annotation = Some(style.is_annotative);
|
||
}
|
||
} else if let Some(codec::EntityType::MultiLeader(ml)) =
|
||
self.tabs[i].scene.document.get_entity_mut(handle)
|
||
{
|
||
match field {
|
||
"text_style_handle" => {
|
||
if let Some(h) = resolved {
|
||
ml.text_style_handle = Some(h);
|
||
ml.context.text_style_handle = Some(h);
|
||
ml.property_override_flags.insert(
|
||
codec::entities::MultiLeaderPropertyOverrideFlags::TEXT_STYLE,
|
||
);
|
||
}
|
||
}
|
||
"arrowhead_handle" => {
|
||
ml.arrowhead_handle = resolved;
|
||
for root in &mut ml.context.leader_roots {
|
||
for line in &mut root.lines {
|
||
line.arrowhead_handle = resolved;
|
||
line.override_flags.insert(
|
||
codec::entities::LeaderLinePropertyOverrideFlags::ARROWHEAD,
|
||
);
|
||
}
|
||
}
|
||
ml.property_override_flags.insert(
|
||
codec::entities::MultiLeaderPropertyOverrideFlags::ARROWHEAD,
|
||
);
|
||
}
|
||
"line_type_handle" => {
|
||
ml.line_type_handle = resolved;
|
||
for root in &mut ml.context.leader_roots {
|
||
for line in &mut root.lines {
|
||
line.line_type_handle = resolved;
|
||
line.override_flags.insert(
|
||
codec::entities::LeaderLinePropertyOverrideFlags::LINE_TYPE,
|
||
);
|
||
}
|
||
}
|
||
ml.property_override_flags.insert(
|
||
codec::entities::MultiLeaderPropertyOverrideFlags::LEADER_LINE_TYPE,
|
||
);
|
||
}
|
||
"block_content_handle" => {
|
||
if let Some(block_handle) = resolved {
|
||
ml.block_content_handle = Some(block_handle);
|
||
ml.context.block_content_handle = Some(block_handle);
|
||
ml.context.has_block_contents = true;
|
||
ml.context.block_content_location =
|
||
ml.context.content_base_point;
|
||
ml.property_override_flags.insert(
|
||
codec::entities::MultiLeaderPropertyOverrideFlags::BLOCK_CONTENT,
|
||
);
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
if let Some(annotative) = style_annotation {
|
||
if annotative {
|
||
if let Some(scale) =
|
||
self.tabs[i].scene.creation_annotation_scale_handle()
|
||
{
|
||
crate::scene::annotative::create_annotation_context(
|
||
&mut self.tabs[i].scene.document,
|
||
handle,
|
||
scale,
|
||
);
|
||
}
|
||
} else {
|
||
crate::scene::annotative::clear_annotation_context(
|
||
&mut self.tabs[i].scene.document,
|
||
handle,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
} else if matches!(field, "arrow_block" | "dim_line_lw" | "text_pos_vert") {
|
||
// Leader dim-var overrides picked from a dropdown. The
|
||
// arrow block resolves a friendly arrowhead name to a
|
||
// block handle ("Closed filled" clears the override →
|
||
// reverts to the style); the other two map a label to
|
||
// the DIMLWD / DIMTAD enum value.
|
||
use crate::entities::dim_override as dov;
|
||
use codec::xdata::XDataValue;
|
||
let arrow_h: Option<codec::Handle> =
|
||
if field == "arrow_block" && value != "Closed filled" {
|
||
self.tabs[i]
|
||
.scene
|
||
.document
|
||
.block_records
|
||
.iter()
|
||
.find(|b| {
|
||
crate::app::properties::arrowhead_label(&b.name) == value
|
||
})
|
||
.map(|b| b.handle)
|
||
} else {
|
||
None
|
||
};
|
||
for &handle in &handles {
|
||
if self.tabs[i].scene.is_layer_locked(handle) {
|
||
continue;
|
||
}
|
||
// Only leaders carry these rows; skip anything else so
|
||
// a multi-type selection can't get stray overrides.
|
||
if !matches!(
|
||
self.tabs[i].scene.document.get_entity(handle),
|
||
Some(codec::EntityType::Leader(_))
|
||
) {
|
||
continue;
|
||
}
|
||
let doc = &mut self.tabs[i].scene.document;
|
||
match field {
|
||
"dim_line_lw" => dov::set(
|
||
doc,
|
||
handle,
|
||
dov::DIMLWD,
|
||
Some(XDataValue::Integer16(
|
||
crate::app::properties::dim_lineweight_from_label(&value),
|
||
)),
|
||
),
|
||
"text_pos_vert" => dov::set(
|
||
doc,
|
||
handle,
|
||
dov::DIMTAD,
|
||
Some(XDataValue::Integer16(
|
||
crate::app::properties::dimtad_from_label(&value),
|
||
)),
|
||
),
|
||
"arrow_block" => {
|
||
// "Closed filled" is an explicit override to
|
||
// the null-handle default arrow (not a clear),
|
||
// so the pick sticks even when the style's
|
||
// arrow differs.
|
||
if value == "Closed filled" {
|
||
dov::set(
|
||
doc,
|
||
handle,
|
||
dov::DIMLDRBLK,
|
||
Some(XDataValue::Handle(codec::Handle::NULL)),
|
||
);
|
||
} else if let Some(h) = arrow_h {
|
||
dov::set(
|
||
doc,
|
||
handle,
|
||
dov::DIMLDRBLK,
|
||
Some(XDataValue::Handle(h)),
|
||
);
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
} else if field == "block" {
|
||
// Name dropdown on a block reference: re-point the
|
||
// selected inserts to the picked definition. A stale
|
||
// typed value in the text buffer would mask the pick,
|
||
// so drop it; the pick also closes the list.
|
||
self.tabs[i]
|
||
.properties
|
||
.edit_buf
|
||
.remove(&crate::ui::properties::FieldKey::Geom("block"));
|
||
self.tabs[i].properties.edit_choice_open = false;
|
||
let canon = self.tabs[i]
|
||
.scene
|
||
.document
|
||
.block_records
|
||
.get(&value)
|
||
.map(|br| br.name.clone());
|
||
if let Some(canon) = canon {
|
||
let mut changes = Vec::new();
|
||
for &handle in &handles {
|
||
if self.tabs[i].scene.is_layer_locked(handle) {
|
||
continue;
|
||
}
|
||
if let Some(codec::EntityType::Insert(ins)) =
|
||
self.tabs[i].scene.document.get_entity_mut(handle)
|
||
{
|
||
if ins.block_name != canon {
|
||
ins.block_name = canon.clone();
|
||
changes.push((
|
||
handle,
|
||
crate::scene::ChangeKind::Modified,
|
||
));
|
||
}
|
||
}
|
||
}
|
||
if !changes.is_empty() {
|
||
self.tabs[i].scene.bump_entities(&changes);
|
||
}
|
||
}
|
||
} else if field == "plot_style" {
|
||
// Named plot-style pick: ByLayer / ByBlock clear the
|
||
// handle; a named style resolves through the drawing's
|
||
// ACAD_PLOTSTYLENAME dictionary to its placeholder handle.
|
||
let dict_h =
|
||
self.tabs[i].scene.document.header.acad_plotstylename_dict_handle;
|
||
let ph: Option<codec::Handle> =
|
||
crate::scene::annotative::as_dict(&self.tabs[i].scene.document, dict_h)
|
||
.and_then(|d| {
|
||
d.entries
|
||
.iter()
|
||
.find(|(n, _)| *n == value)
|
||
.map(|(_, h)| *h)
|
||
});
|
||
for &handle in &handles {
|
||
if self.tabs[i].scene.is_layer_locked(handle) {
|
||
continue;
|
||
}
|
||
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle)
|
||
{
|
||
let common = entity.common_mut();
|
||
match value.as_str() {
|
||
"ByLayer" => {
|
||
common.plotstyle_flags = 0;
|
||
common.plotstyle_handle = None;
|
||
}
|
||
"ByBlock" => {
|
||
common.plotstyle_flags = 1;
|
||
common.plotstyle_handle = None;
|
||
}
|
||
"Normal" => {
|
||
common.plotstyle_flags = 2;
|
||
common.plotstyle_handle = None;
|
||
}
|
||
_ => {
|
||
if let Some(h) = ph {
|
||
common.plotstyle_flags = 3;
|
||
common.plotstyle_handle = Some(h);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
let plane = if self.tabs[i].editing_model_space() {
|
||
self.tabs[i].ucs_xform().working_plane()
|
||
} else {
|
||
crate::command::WorkingPlane::default()
|
||
};
|
||
for &handle in &handles {
|
||
let mline_style = self.tabs[i]
|
||
.scene
|
||
.document
|
||
.get_entity(handle)
|
||
.and_then(|entity| match entity {
|
||
codec::EntityType::MLine(mline) => {
|
||
crate::entities::mline::resolved_mline_style(
|
||
mline,
|
||
&self.tabs[i].scene.document,
|
||
)
|
||
.cloned()
|
||
}
|
||
_ => None,
|
||
});
|
||
if matches!(field, "text_x" | "text_y") {
|
||
crate::entities::dimension::materialize_large_radial_text_position(
|
||
&mut self.tabs[i].scene.document,
|
||
handle,
|
||
&value,
|
||
);
|
||
}
|
||
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle)
|
||
{
|
||
crate::scene::view::dispatch::apply_geom_prop_in_working_plane(
|
||
entity,
|
||
field,
|
||
&value,
|
||
plane,
|
||
);
|
||
if matches!(field, "ml_justification" | "ml_scale") {
|
||
if let (
|
||
codec::EntityType::MLine(mline),
|
||
Some(style),
|
||
) = (entity, mline_style.as_ref())
|
||
{
|
||
crate::modules::draw::draw::mline::sync_mline_element_parameters(
|
||
mline, style,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if field.starts_with("tbl_") {
|
||
for &handle in &handles {
|
||
if let Some(codec::EntityType::Table(table)) =
|
||
self.tabs[i].scene.document.get_entity_mut(handle)
|
||
{
|
||
table.block_record_handle = None;
|
||
}
|
||
}
|
||
}
|
||
self.invalidate_property_targets(i, &handles);
|
||
self.tabs[i].dirty = true;
|
||
self.tabs[i].properties.edit_choice_open = false;
|
||
if field == "spline_method" {
|
||
self.tabs[i].properties.prop_vertex = 0;
|
||
self.tabs[i].properties.prop_vertex_indicator_active = false;
|
||
}
|
||
self.refresh_properties();
|
||
} else {
|
||
match field {
|
||
"transparency" => {
|
||
let Some(transparency) = crate::scene::creation_style::parse_current_transparency(&value) else {
|
||
self.command_line.push_error("Transparency: expected ByLayer, ByBlock, or an integer from 0 to 90.");
|
||
self.refresh_properties();
|
||
return Task::none();
|
||
};
|
||
if self.tabs[i].scene.document.current_entity_transparency()
|
||
!= transparency
|
||
{
|
||
self.push_undo_snapshot(i, "CETRANSPARENCY");
|
||
if self.tabs[i]
|
||
.scene
|
||
.document
|
||
.set_current_entity_transparency(transparency)
|
||
{
|
||
self.tabs[i].dirty = true;
|
||
} else {
|
||
self.discard_last_undo_entry(i);
|
||
self.command_line.push_error(
|
||
"CETRANSPARENCY: drawing variable dictionary is invalid.",
|
||
);
|
||
}
|
||
}
|
||
self.refresh_properties();
|
||
}
|
||
"material" => {
|
||
let mat_handle: Option<codec::Handle> = self.tabs[i]
|
||
.scene
|
||
.document
|
||
.objects
|
||
.iter()
|
||
.find_map(|(h, o)| match o {
|
||
codec::objects::ObjectType::Material(m) if m.name == value => {
|
||
Some(*h)
|
||
}
|
||
_ => None,
|
||
});
|
||
match value.as_str() {
|
||
"ByLayer" | "ByBlock" | "Global" => {
|
||
self.tabs[i].scene.document.header.current_material_handle =
|
||
codec::Handle::NULL;
|
||
}
|
||
_ => {
|
||
if let Some(h) = mat_handle {
|
||
self.tabs[i].scene.document.header.current_material_handle =
|
||
h;
|
||
}
|
||
}
|
||
}
|
||
self.tabs[i].dirty = true;
|
||
self.refresh_properties();
|
||
}
|
||
"plot_style" => {
|
||
if self.tabs[i].scene.document.header.plotstyle_mode {
|
||
self.refresh_properties();
|
||
return Task::none();
|
||
}
|
||
match value.as_str() {
|
||
"ByBlock" => {
|
||
self.tabs[i].scene.document.header.current_plotstyle_type = 1
|
||
}
|
||
"Normal" => {
|
||
self.tabs[i].scene.document.header.current_plotstyle_type = 2
|
||
}
|
||
_ => self.tabs[i].scene.document.header.current_plotstyle_type = 0,
|
||
}
|
||
self.tabs[i].dirty = true;
|
||
self.refresh_properties();
|
||
}
|
||
"view_plot_style_table" => {
|
||
let next = if value.eq_ignore_ascii_case("None") {
|
||
String::new()
|
||
} else {
|
||
value.clone()
|
||
};
|
||
let layout_name = self.tabs[i].scene.current_layout.clone();
|
||
let current = self.tabs[i]
|
||
.scene
|
||
.document
|
||
.objects
|
||
.values()
|
||
.find_map(|object| {
|
||
let codec::objects::ObjectType::Layout(layout) = object
|
||
else {
|
||
return None;
|
||
};
|
||
layout
|
||
.name
|
||
.eq_ignore_ascii_case(&layout_name)
|
||
.then(|| layout.plot_style_sheet.clone())
|
||
})
|
||
.unwrap_or_else(|| {
|
||
self.tabs[i].scene.document.header.stylesheet.clone()
|
||
});
|
||
if current != next {
|
||
self.push_undo_snapshot(i, "PLOTSTYLE");
|
||
for object in self.tabs[i].scene.document.objects.values_mut() {
|
||
if let codec::objects::ObjectType::Layout(layout) = object {
|
||
if layout.name.eq_ignore_ascii_case(&layout_name) {
|
||
layout.plot_style_sheet = next.clone();
|
||
layout.plot_flags.plot_plot_styles = !next.is_empty();
|
||
layout.plot_flags.show_plot_styles = !next.is_empty();
|
||
}
|
||
}
|
||
}
|
||
self.tabs[i].scene.document.header.stylesheet = next;
|
||
self.tabs[i].dirty = true;
|
||
}
|
||
self.refresh_properties();
|
||
}
|
||
"view_annotation_scale" => {
|
||
let current = self.tabs[i]
|
||
.scene
|
||
.document
|
||
.header
|
||
.current_annotation_scale
|
||
.clone();
|
||
if !current.eq_ignore_ascii_case(&value) {
|
||
self.push_undo_snapshot(i, "CANNOSCALE");
|
||
if self.tabs[i].scene.set_annotation_scale_named(&value).is_some() {
|
||
self.tabs[i].dirty = true;
|
||
} else {
|
||
self.discard_last_undo_entry(i);
|
||
}
|
||
}
|
||
self.refresh_properties();
|
||
}
|
||
"view_ucs_icon_on" => {
|
||
let next = value.eq_ignore_ascii_case("Yes");
|
||
let active_viewport = self.tabs[i].scene.active_viewport;
|
||
let current = active_viewport
|
||
.and_then(|handle| self.tabs[i].scene.document.get_entity(handle))
|
||
.and_then(|entity| match entity {
|
||
codec::EntityType::Viewport(viewport) => {
|
||
Some(viewport.ucs_icon_visible)
|
||
}
|
||
_ => None,
|
||
})
|
||
.or_else(|| {
|
||
self.tabs[i]
|
||
.scene
|
||
.document
|
||
.vports
|
||
.iter()
|
||
.find(|viewport| {
|
||
viewport
|
||
.name
|
||
.trim_start_matches('*')
|
||
.eq_ignore_ascii_case("active")
|
||
})
|
||
.map(|viewport| viewport.ucsicon_lower)
|
||
})
|
||
.unwrap_or(self.show_ucs_icon);
|
||
if current != next {
|
||
self.push_undo_snapshot(i, "UCSICON");
|
||
if let Some(handle) = active_viewport {
|
||
if let Some(codec::EntityType::Viewport(viewport)) =
|
||
self.tabs[i].scene.document.get_entity_mut(handle)
|
||
{
|
||
viewport.ucs_icon_visible = next;
|
||
}
|
||
} else if let Some(viewport) = self.tabs[i]
|
||
.scene
|
||
.document
|
||
.vports
|
||
.iter_mut()
|
||
.find(|viewport| {
|
||
viewport
|
||
.name
|
||
.trim_start_matches('*')
|
||
.eq_ignore_ascii_case("active")
|
||
})
|
||
{
|
||
viewport.ucsicon_lower = next;
|
||
}
|
||
self.show_ucs_icon = next;
|
||
self.persist_settings_if_changed();
|
||
self.tabs[i].dirty = true;
|
||
}
|
||
self.refresh_properties();
|
||
}
|
||
"view_ucs_icon_at_origin" => {
|
||
let next = value.eq_ignore_ascii_case("Yes");
|
||
let active_viewport = self.tabs[i].scene.active_viewport;
|
||
let current = active_viewport
|
||
.and_then(|handle| self.tabs[i].scene.document.get_entity(handle))
|
||
.and_then(|entity| match entity {
|
||
codec::EntityType::Viewport(viewport) => {
|
||
Some(viewport.status.ucs_icon_at_origin)
|
||
}
|
||
_ => None,
|
||
})
|
||
.or_else(|| {
|
||
self.tabs[i]
|
||
.scene
|
||
.document
|
||
.vports
|
||
.iter()
|
||
.find(|viewport| {
|
||
viewport
|
||
.name
|
||
.trim_start_matches('*')
|
||
.eq_ignore_ascii_case("active")
|
||
})
|
||
.map(|viewport| viewport.ucsicon_origin)
|
||
})
|
||
.unwrap_or(self.ucs_icon_at_origin);
|
||
if current != next {
|
||
self.push_undo_snapshot(i, "UCSICON");
|
||
if let Some(handle) = active_viewport {
|
||
if let Some(codec::EntityType::Viewport(viewport)) =
|
||
self.tabs[i].scene.document.get_entity_mut(handle)
|
||
{
|
||
viewport.status.ucs_icon_at_origin = next;
|
||
}
|
||
} else if let Some(viewport) = self.tabs[i]
|
||
.scene
|
||
.document
|
||
.vports
|
||
.iter_mut()
|
||
.find(|viewport| {
|
||
viewport
|
||
.name
|
||
.trim_start_matches('*')
|
||
.eq_ignore_ascii_case("active")
|
||
})
|
||
{
|
||
viewport.ucsicon_origin = next;
|
||
}
|
||
self.ucs_icon_at_origin = next;
|
||
self.persist_settings_if_changed();
|
||
self.tabs[i].dirty = true;
|
||
}
|
||
self.refresh_properties();
|
||
}
|
||
"view_ucs_per_viewport" => {
|
||
let next = value.eq_ignore_ascii_case("Yes");
|
||
let active_viewport = self.tabs[i].scene.active_viewport;
|
||
let current = active_viewport
|
||
.and_then(|handle| self.tabs[i].scene.document.get_entity(handle))
|
||
.and_then(|entity| match entity {
|
||
codec::EntityType::Viewport(viewport) => {
|
||
Some(viewport.ucs_per_viewport)
|
||
}
|
||
_ => None,
|
||
})
|
||
.or_else(|| {
|
||
self.tabs[i]
|
||
.scene
|
||
.document
|
||
.vports
|
||
.iter()
|
||
.find(|viewport| {
|
||
viewport
|
||
.name
|
||
.trim_start_matches('*')
|
||
.eq_ignore_ascii_case("active")
|
||
})
|
||
.map(|viewport| viewport.ucs_per_viewport)
|
||
})
|
||
.unwrap_or(true);
|
||
if current != next {
|
||
self.push_undo_snapshot(i, "UCSVP");
|
||
if let Some(handle) = active_viewport {
|
||
if let Some(codec::EntityType::Viewport(viewport)) =
|
||
self.tabs[i].scene.document.get_entity_mut(handle)
|
||
{
|
||
viewport.ucs_per_viewport = next;
|
||
}
|
||
} else if let Some(viewport) = self.tabs[i]
|
||
.scene
|
||
.document
|
||
.vports
|
||
.iter_mut()
|
||
.find(|viewport| {
|
||
viewport
|
||
.name
|
||
.trim_start_matches('*')
|
||
.eq_ignore_ascii_case("active")
|
||
})
|
||
{
|
||
viewport.ucs_per_viewport = next;
|
||
}
|
||
self.tabs[i].dirty = true;
|
||
}
|
||
self.refresh_properties();
|
||
}
|
||
"view_visual_style" => {
|
||
use codec::entities::ViewportRenderMode as Mode;
|
||
let mode = match value.as_str() {
|
||
"2D Wireframe" | "Wireframe 2D" => Some(Mode::Wireframe2D),
|
||
"3D Wireframe" | "Wireframe 3D" => Some(Mode::Wireframe3D),
|
||
"Hidden Line" => Some(Mode::HiddenLine),
|
||
"Flat Shaded" => Some(Mode::FlatShaded),
|
||
"Gouraud Shaded" => Some(Mode::GouraudShaded),
|
||
"Flat Shaded + Edges" => Some(Mode::FlatShadedWithEdges),
|
||
"Gouraud Shaded + Edges" => {
|
||
Some(Mode::GouraudShadedWithEdges)
|
||
}
|
||
_ => None,
|
||
};
|
||
if let Some(mode) = mode {
|
||
let task = self.on_set_render_mode(mode);
|
||
self.refresh_properties();
|
||
return task;
|
||
}
|
||
self.refresh_properties();
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
Task::none()
|
||
}
|
||
|
||
pub(super) fn on_prop_geom_commit(&mut self, field: &'static str) -> Task<Message> {
|
||
use crate::ui::window::named_parameters::ParamField;
|
||
// A dynamic dimension's Name/Expression rows edit its parameter.
|
||
let dynamic_field = match field {
|
||
"dyn_constraint_name" => Some(ParamField::Name),
|
||
"dyn_constraint_expression" => Some(ParamField::Formula),
|
||
_ => None,
|
||
};
|
||
if let Some(param_field) = dynamic_field {
|
||
return self.on_dynamic_dimension_field_commit(field, param_field);
|
||
}
|
||
if field == "dyn_constraint_description" {
|
||
return self.on_dynamic_dimension_description_commit(field);
|
||
}
|
||
let i = self.active_tab;
|
||
self.tabs[i].properties.active_field = None;
|
||
let handles = self.property_target_handles(i);
|
||
if !handles.is_empty() {
|
||
let driven_reference = match field {
|
||
"start_x" | "start_y" | "start_z" => Some(Some(0)),
|
||
"end_x" | "end_y" | "end_z" => Some(Some(1)),
|
||
"center_x" | "center_y" | "center_z" => Some(Some(-3)),
|
||
"radius" | "diameter" | "circumference" | "area"
|
||
| "major_r" | "minor_r" | "ratio" => Some(None),
|
||
_ => None,
|
||
};
|
||
let retain_size = self.constraint_solve_mode
|
||
&& driven_reference.is_some_and(|marker| marker.is_some());
|
||
let retained_originals: Vec<_> = if retain_size {
|
||
handles
|
||
.iter()
|
||
.filter_map(|handle| {
|
||
self.tabs[i]
|
||
.scene
|
||
.document
|
||
.get_entity(*handle)
|
||
.cloned()
|
||
.map(|entity| (*handle, entity))
|
||
})
|
||
.collect()
|
||
} else {
|
||
Vec::new()
|
||
};
|
||
let evaluates_expression = self.tabs[i]
|
||
.properties
|
||
.sections
|
||
.iter()
|
||
.flat_map(|section| section.props.iter())
|
||
.find(|property| property.field == field)
|
||
.is_some_and(|property| {
|
||
matches!(property.value, PropValue::EditText(_))
|
||
});
|
||
if let Some(raw_val) = self.tabs[i]
|
||
.properties
|
||
.edit_buf
|
||
.remove(&crate::ui::properties::FieldKey::Geom(field))
|
||
{
|
||
let val = if evaluates_expression {
|
||
crate::app::expr_eval::eval_to_string(&raw_val)
|
||
} else {
|
||
raw_val
|
||
};
|
||
// Underlay rows: reject what the reference rejects, and
|
||
// write a Width / Height as the scale that produces it.
|
||
if field.starts_with("ext_pcx_") {
|
||
if let Err(message) =
|
||
crate::entities::extended::validate_point_cloud_property(field, &val)
|
||
{
|
||
self.command_line.push_error(message);
|
||
return Task::none();
|
||
}
|
||
}
|
||
if field.starts_with("att_") {
|
||
if let Err(message) =
|
||
crate::entities::attribute::validate_definition_property(field, &val)
|
||
{
|
||
self.command_line.push_error(message);
|
||
return Task::none();
|
||
}
|
||
}
|
||
if field.starts_with("ul_") {
|
||
if let Err(message) =
|
||
crate::entities::underlay::validate_property(field, &val)
|
||
{
|
||
self.command_line.push_error(message);
|
||
return Task::none();
|
||
}
|
||
}
|
||
let (field, val) = match field {
|
||
"ul_width" | "ul_height" => {
|
||
let scale = handles.iter().find_map(|handle| {
|
||
match self.tabs[i].scene.document.get_entity(*handle) {
|
||
Some(codec::EntityType::Underlay(underlay)) => {
|
||
crate::entities::underlay::size_to_scale(
|
||
underlay,
|
||
&self.tabs[i].scene.document,
|
||
field,
|
||
&val,
|
||
)
|
||
}
|
||
_ => None,
|
||
}
|
||
});
|
||
match scale {
|
||
Some(scale) => ("ul_scale", scale),
|
||
None => return Task::none(),
|
||
}
|
||
}
|
||
_ => (field, val),
|
||
};
|
||
if matches!(
|
||
field,
|
||
"current_fit_point" | "current_control_point" | "pm_current_vertex"
|
||
) {
|
||
let count = handles
|
||
.iter()
|
||
.filter_map(|handle| {
|
||
let entity = self.tabs[i].scene.document.get_entity(*handle)?;
|
||
match (field, entity) {
|
||
(
|
||
"current_fit_point",
|
||
codec::EntityType::Spline(spline),
|
||
) => Some(spline.fit_points.len()),
|
||
(
|
||
"current_control_point",
|
||
codec::EntityType::Spline(spline),
|
||
) => Some(
|
||
crate::entities::spline::control_vertex_count(spline),
|
||
),
|
||
(
|
||
"pm_current_vertex",
|
||
codec::EntityType::PolygonMesh(mesh),
|
||
) => Some(mesh.vertices.len()),
|
||
_ => None,
|
||
}
|
||
})
|
||
.min()
|
||
.unwrap_or(0);
|
||
if let Ok(requested) = val.trim().parse::<usize>() {
|
||
if count > 0 {
|
||
let next = requested.clamp(1, count) - 1;
|
||
self.tabs[i].properties.prop_vertex = next;
|
||
self.tabs[i].properties.prop_vertex_indicator_active = true;
|
||
}
|
||
}
|
||
self.refresh_properties();
|
||
return Task::none();
|
||
}
|
||
self.push_undo_snapshot(i, "CHPROP");
|
||
if field == "tbl_style_handle" {
|
||
let style_handle = self.tabs[i]
|
||
.scene
|
||
.document
|
||
.objects
|
||
.iter()
|
||
.find_map(|(handle, object)| match object {
|
||
codec::objects::ObjectType::TableStyle(style)
|
||
if style.name.eq_ignore_ascii_case(val.trim()) =>
|
||
{
|
||
Some(*handle)
|
||
}
|
||
_ => None,
|
||
});
|
||
if let Some(style_handle) = style_handle {
|
||
for &handle in &handles {
|
||
if let Some(codec::EntityType::Table(table)) =
|
||
self.tabs[i].scene.document.get_entity_mut(handle)
|
||
{
|
||
table.table_style_handle = Some(style_handle);
|
||
}
|
||
}
|
||
}
|
||
} else if let Some(axis) = match field {
|
||
"xref_x_scale" => Some(0),
|
||
"xref_y_scale" => Some(1),
|
||
"xref_z_scale" => Some(2),
|
||
_ => None,
|
||
} {
|
||
// An xref's Scale rows show the scale without the
|
||
// unit conversion its INSERT carries; write it back
|
||
// with that conversion.
|
||
let value = val
|
||
.trim()
|
||
.parse::<f64>()
|
||
.ok()
|
||
.filter(|v| v.is_finite() && *v != 0.0);
|
||
if let Some(value) = value {
|
||
let host = self.tabs[i].scene.document.header.insertion_units;
|
||
for &handle in &handles {
|
||
let factor = match self.tabs[i].scene.document.get_entity(handle) {
|
||
Some(codec::EntityType::Insert(ins)) => self.tabs[i]
|
||
.scene
|
||
.document
|
||
.block_records
|
||
.get(&ins.block_name)
|
||
.and_then(|br| {
|
||
crate::app::properties::insert_unit_scale(host, br.units)
|
||
})
|
||
.unwrap_or(1.0),
|
||
_ => continue,
|
||
};
|
||
if let Some(codec::EntityType::Insert(ins)) =
|
||
self.tabs[i].scene.document.get_entity_mut(handle)
|
||
{
|
||
match axis {
|
||
0 => ins.set_x_scale(value * factor),
|
||
1 => ins.set_y_scale(value * factor),
|
||
_ => ins.set_z_scale(value * factor),
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} else if field == "block" {
|
||
// Name row on a block reference: an existing name
|
||
// re-points the selected inserts; a new one renames
|
||
// the definition they share. Commit closes the list.
|
||
self.tabs[i].properties.edit_choice_open = false;
|
||
self.apply_block_name_commit(i, &handles, val.trim());
|
||
} else if field == "frozen_layers" {
|
||
// Resolve layer names → handles, then apply to viewports.
|
||
let layer_handles: Vec<codec::Handle> = val
|
||
.split(',')
|
||
.map(|s| s.trim())
|
||
.filter(|s| !s.is_empty())
|
||
.filter_map(|name| {
|
||
self.tabs[i]
|
||
.scene
|
||
.document
|
||
.layers
|
||
.iter()
|
||
.find(|l| l.name.eq_ignore_ascii_case(name))
|
||
.map(|l| l.handle)
|
||
})
|
||
.collect();
|
||
for &handle in &handles {
|
||
if let Some(codec::EntityType::Viewport(vp)) =
|
||
self.tabs[i].scene.document.get_entity_mut(handle)
|
||
{
|
||
vp.frozen_layers = layer_handles.clone();
|
||
}
|
||
}
|
||
} else {
|
||
// Per-vertex geometry edits (vertex_x/y, widths) target
|
||
// the vertex the Current Vertex stepper is on. (polyline)
|
||
crate::scene::view::dispatch::set_prop_current_vertex(
|
||
self.tabs[i].properties.prop_vertex,
|
||
);
|
||
let plane = if self.tabs[i].editing_model_space() {
|
||
self.tabs[i].ucs_xform().working_plane()
|
||
} else {
|
||
crate::command::WorkingPlane::default()
|
||
};
|
||
for &handle in &handles {
|
||
// Skip objects on a locked layer.
|
||
if self.tabs[i].scene.is_layer_locked(handle) {
|
||
continue;
|
||
}
|
||
match field {
|
||
"tol_text_height" => {
|
||
use crate::entities::dim_override as dov;
|
||
let trimmed = val.trim();
|
||
if trimmed.is_empty() {
|
||
dov::set(
|
||
&mut self.tabs[i].scene.document,
|
||
handle,
|
||
dov::DIMTXT,
|
||
None,
|
||
);
|
||
} else if let Ok(height) = trimmed.parse::<f64>() {
|
||
if height > 0.0 {
|
||
dov::set(
|
||
&mut self.tabs[i].scene.document,
|
||
handle,
|
||
dov::DIMTXT,
|
||
Some(codec::xdata::XDataValue::Real(height)),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
_ if field.starts_with("dim_") => {
|
||
if matches!(
|
||
self.tabs[i].scene.document.get_entity(handle),
|
||
Some(codec::EntityType::Dimension(_))
|
||
) {
|
||
let applied = crate::entities::dim_override::set_property(
|
||
&mut self.tabs[i].scene.document,
|
||
handle,
|
||
field,
|
||
&val,
|
||
);
|
||
if applied && field == "dim_text_inside" {
|
||
if let Some(codec::EntityType::Dimension(
|
||
codec::entities::Dimension::LargeRadial(
|
||
dimension,
|
||
),
|
||
)) = self.tabs[i]
|
||
.scene
|
||
.document
|
||
.get_entity_mut(handle)
|
||
{
|
||
dimension.base.text_user_positioned = false;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
"hyperlink" => {
|
||
// Stored in the standard PE_URL XDATA
|
||
// record; an empty value clears it.
|
||
let vals = if val.trim().is_empty() {
|
||
None
|
||
} else {
|
||
Some(vec![codec::xdata::XDataValue::String(
|
||
val.clone(),
|
||
)])
|
||
};
|
||
crate::scene::view::dispatch::set_entity_xdata(
|
||
&mut self.tabs[i].scene.document,
|
||
handle,
|
||
"PE_URL",
|
||
vals,
|
||
);
|
||
}
|
||
"arrow_size" | "text_offset" | "dim_scale_overall" => {
|
||
// Real-valued leader dim-var overrides
|
||
// (ACAD_DSTYLE). A blank value clears the
|
||
// override → reverts to the style; an
|
||
// unparseable entry is ignored so a typo
|
||
// can't silently wipe a stored override.
|
||
use crate::entities::dim_override as dov;
|
||
let code = match field {
|
||
"arrow_size" => dov::DIMASZ,
|
||
"text_offset" => dov::DIMGAP,
|
||
_ => dov::DIMSCALE,
|
||
};
|
||
let trimmed = val.trim();
|
||
if trimmed.is_empty() {
|
||
dov::set(
|
||
&mut self.tabs[i].scene.document,
|
||
handle,
|
||
code,
|
||
None,
|
||
);
|
||
} else if let Ok(n) = trimmed.parse::<f64>() {
|
||
dov::set(
|
||
&mut self.tabs[i].scene.document,
|
||
handle,
|
||
code,
|
||
Some(codec::xdata::XDataValue::Real(n)),
|
||
);
|
||
}
|
||
}
|
||
"linetype_scale" | "transparency" | "thickness" => {
|
||
// Thickness (DXF 39 extrusion) is a General-
|
||
// group common prop, not a geometry-group one:
|
||
// route it to apply_common_prop (which handles
|
||
// it via set_entity_thickness). apply_geom_prop
|
||
// ignores it, so the edit would otherwise be a
|
||
// silent no-op and revert to the old value.
|
||
if let Some(entity) =
|
||
self.tabs[i].scene.document.get_entity_mut(handle)
|
||
{
|
||
crate::scene::view::dispatch::apply_common_prop(
|
||
entity, field, &val,
|
||
);
|
||
}
|
||
}
|
||
_ => {
|
||
if crate::scene::model::solid_history::is_primitive_property(
|
||
field,
|
||
) {
|
||
self.tabs[i].scene.apply_solid_history_property(
|
||
handle,
|
||
field,
|
||
&val,
|
||
);
|
||
} else if self.tabs[i]
|
||
.scene
|
||
.apply_solid_position_property(
|
||
handle,
|
||
field,
|
||
&val,
|
||
plane,
|
||
)
|
||
.is_none()
|
||
{
|
||
let mline_style = self.tabs[i]
|
||
.scene
|
||
.document
|
||
.get_entity(handle)
|
||
.and_then(|entity| match entity {
|
||
codec::EntityType::MLine(mline) => {
|
||
crate::entities::mline::resolved_mline_style(
|
||
mline,
|
||
&self.tabs[i].scene.document,
|
||
)
|
||
.cloned()
|
||
}
|
||
_ => None,
|
||
});
|
||
if matches!(field, "text_x" | "text_y") {
|
||
crate::entities::dimension::materialize_large_radial_text_position(
|
||
&mut self.tabs[i].scene.document,
|
||
handle,
|
||
&val,
|
||
);
|
||
}
|
||
if let Some(entity) = self.tabs[i]
|
||
.scene
|
||
.document
|
||
.get_entity_mut(handle)
|
||
{
|
||
crate::scene::view::dispatch::apply_geom_prop_in_working_plane(
|
||
entity,
|
||
field,
|
||
&val,
|
||
plane,
|
||
);
|
||
if field == "ml_scale" {
|
||
if let (
|
||
codec::EntityType::MLine(mline),
|
||
Some(style),
|
||
) = (entity, mline_style.as_ref())
|
||
{
|
||
crate::modules::draw::draw::mline::sync_mline_element_parameters(
|
||
mline, style,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
if matches!(field, "srf_u_isolines" | "srf_v_isolines")
|
||
{
|
||
self.tabs[i].scene.reseed_derived_caches(handle);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if field.starts_with("tbl_") {
|
||
for &handle in &handles {
|
||
if let Some(codec::EntityType::Table(table)) =
|
||
self.tabs[i].scene.document.get_entity_mut(handle)
|
||
{
|
||
table.block_record_handle = None;
|
||
}
|
||
}
|
||
}
|
||
let driven_refs: Vec<_> = driven_reference
|
||
.into_iter()
|
||
.flat_map(|marker| {
|
||
handles.iter().copied().map(move |handle| match marker {
|
||
Some(marker) => {
|
||
crate::scene::parametric_constraints::ParametricRef::point(
|
||
handle, marker,
|
||
)
|
||
}
|
||
None => {
|
||
crate::scene::parametric_constraints::ParametricRef::whole(
|
||
handle,
|
||
)
|
||
}
|
||
})
|
||
})
|
||
.collect();
|
||
self.invalidate_property_targets_with_originals(
|
||
i,
|
||
&handles,
|
||
&driven_refs,
|
||
retain_size,
|
||
&retained_originals,
|
||
);
|
||
self.tabs[i].dirty = true;
|
||
self.refresh_properties();
|
||
}
|
||
} else {
|
||
let _ = self.tabs[i]
|
||
.properties
|
||
.edit_buf
|
||
.remove(&crate::ui::properties::FieldKey::Geom(field));
|
||
self.refresh_properties();
|
||
}
|
||
Task::none()
|
||
}
|
||
|
||
/// Properties-panel Name commit for block references. A `new` matching an
|
||
/// existing regular block re-points the selected inserts to it; otherwise
|
||
/// the definition the (single-block) selection references is renamed and
|
||
/// every insert of it follows. Anonymous/xref definitions never get here —
|
||
/// their Name row stays read-only.
|
||
fn apply_block_name_commit(&mut self, i: usize, handles: &[codec::Handle], new: &str) {
|
||
if new.is_empty() {
|
||
return;
|
||
}
|
||
// Existing target → swap the selected references to it. Anonymous,
|
||
// xref and xref-dependent definitions are not valid targets.
|
||
let target = self.tabs[i].scene.document.block_records.get(new).map(|br| {
|
||
(
|
||
br.name.clone(),
|
||
br.is_anonymous() || br.flags.is_xref || br.name.contains('|'),
|
||
)
|
||
});
|
||
if let Some((canon, protected)) = target {
|
||
if protected {
|
||
return;
|
||
}
|
||
let mut changes = Vec::new();
|
||
for &handle in handles {
|
||
if self.tabs[i].scene.is_layer_locked(handle) {
|
||
continue;
|
||
}
|
||
if let Some(codec::EntityType::Insert(ins)) =
|
||
self.tabs[i].scene.document.get_entity_mut(handle)
|
||
{
|
||
if ins.block_name != canon {
|
||
ins.block_name = canon.clone();
|
||
changes.push((handle, crate::scene::ChangeKind::Modified));
|
||
}
|
||
}
|
||
}
|
||
if !changes.is_empty() {
|
||
self.tabs[i].scene.bump_entities(&changes);
|
||
}
|
||
return;
|
||
}
|
||
// New name → rename. Only when every selected insert references the
|
||
// same definition; a mixed selection makes the rename target ambiguous.
|
||
let mut old: Option<String> = None;
|
||
for &handle in handles {
|
||
if let Some(codec::EntityType::Insert(ins)) =
|
||
self.tabs[i].scene.document.get_entity(handle)
|
||
{
|
||
match &old {
|
||
None => old = Some(ins.block_name.clone()),
|
||
Some(o) if o.eq_ignore_ascii_case(&ins.block_name) => {}
|
||
_ => return,
|
||
}
|
||
}
|
||
}
|
||
if let Some(old) = old {
|
||
self.tabs[i].scene.rename_block(&old, new);
|
||
}
|
||
}
|
||
|
||
/// Commit an edited block-attribute value from the Properties panel.
|
||
/// Writes the new value into the matching attribute (by tag) of every
|
||
/// selected INSERT, then re-tessellates so the attribute text repaints.
|
||
/// The value is stored verbatim (attribute values are free-form text, so
|
||
/// it is not run through the expression evaluator like numeric fields).
|
||
pub(super) fn on_prop_attr_commit(&mut self, tag: String) -> Task<Message> {
|
||
let i = self.active_tab;
|
||
self.tabs[i].properties.active_field = None;
|
||
let key = crate::ui::properties::attr_edit_key(&tag);
|
||
let handles = self.property_target_handles(i);
|
||
if handles.is_empty() {
|
||
return Task::none();
|
||
}
|
||
let Some(val) = self.tabs[i].properties.edit_buf.remove(&key) else {
|
||
return Task::none();
|
||
};
|
||
self.push_undo_snapshot(i, "ATTEDIT");
|
||
for &handle in &handles {
|
||
if let Some(codec::EntityType::Insert(ins)) =
|
||
self.tabs[i].scene.document.get_entity_mut(handle)
|
||
{
|
||
if let Some(attr) = ins.attributes.iter_mut().find(|a| a.tag == tag) {
|
||
attr.set_value(val.clone());
|
||
}
|
||
}
|
||
}
|
||
self.invalidate_property_targets(i, &handles);
|
||
self.tabs[i].dirty = true;
|
||
self.refresh_properties();
|
||
Task::none()
|
||
}
|
||
|
||
/// Open the attribute editor dialog for the given INSERT, loading a working
|
||
/// copy of its attribute values. No-op (with a hint) when the block has no
|
||
/// attributes. Entry points: double-clicking such a block, or ATTEDIT.
|
||
pub(crate) fn open_attribute_editor(&mut self, handle: codec::Handle) {
|
||
let i = self.active_tab;
|
||
if self.reject_locked_edit(i, handle) {
|
||
return;
|
||
}
|
||
let doc = &self.tabs[i].scene.document;
|
||
// Ok((block, rows)) to open; Err(msg) to report and stay closed. The
|
||
// borrow of `doc` ends with this match, before any `self` mutation.
|
||
let result = match doc.get_entity(handle) {
|
||
Some(codec::EntityType::Insert(ins)) if !ins.attributes.is_empty() => {
|
||
// The prompt text lives on the block's ATTDEFs, not on the
|
||
// attribute instances — map tag → prompt from the definition.
|
||
let prompts = block_attr_prompts(doc, &ins.block_name);
|
||
let rows = ins
|
||
.attributes
|
||
.iter()
|
||
.map(|a| attr_row_from_entity(a, &prompts))
|
||
.collect::<Vec<_>>();
|
||
Ok((ins.block_name.clone(), rows))
|
||
}
|
||
Some(codec::EntityType::Insert(_)) => {
|
||
Err("ATTEDIT This block has no attributes.")
|
||
}
|
||
_ => Err("ATTEDIT Select a block with attributes."),
|
||
};
|
||
match result {
|
||
Ok((block, rows)) => {
|
||
self.attr_editor_block = block;
|
||
self.attr_editor_rows = rows;
|
||
self.attr_editor_selected = 0;
|
||
self.attr_editor_tab = AttrTab::Attribute;
|
||
self.attr_editor_handle = Some(handle);
|
||
self.active_modal = Some(crate::app::ModalKind::AttributeEditor);
|
||
self.modal_offset = iced::Vector::ZERO;
|
||
self.modal_resize = iced::Vector::ZERO;
|
||
}
|
||
Err(msg) => self.command_line.push_error(msg),
|
||
}
|
||
}
|
||
|
||
/// The currently-selected editor row, for the Text Options / Properties
|
||
/// edit handlers (which all act on that one attribute).
|
||
pub(super) fn attr_row_selected_mut(&mut self) -> Option<&mut AttrRow> {
|
||
let sel = self.attr_editor_selected;
|
||
self.attr_editor_rows.get_mut(sel)
|
||
}
|
||
|
||
/// Close the attribute editor without applying, if it is open. Used when
|
||
/// the tab it belongs to closes or the active tab switches — its handle is
|
||
/// document-local and must not outlive its tab.
|
||
pub(super) fn cancel_attr_editor(&mut self) {
|
||
if self.active_modal == Some(crate::app::ModalKind::AttributeEditor) {
|
||
self.active_modal = None;
|
||
self.reset_modal_geometry();
|
||
}
|
||
self.attr_editor_handle = None;
|
||
self.attr_editor_block.clear();
|
||
self.attr_editor_rows.clear();
|
||
self.attr_editor_selected = 0;
|
||
self.attr_editor_tab = AttrTab::Attribute;
|
||
}
|
||
|
||
/// Commit every edited attribute (value, text options, common properties)
|
||
/// back to the block, keeping the dialog open (Apply). Closing is the frame
|
||
/// ✕ (`CloseModal`), which discards any un-applied edits — matching the
|
||
/// other modal windows. Applied positionally so blocks with duplicate tags
|
||
/// stay correct; guarded on block name + count so an edit can never land on
|
||
/// a different entity if the selection changed under the open dialog.
|
||
pub(super) fn on_attr_editor_apply(&mut self) -> Task<Message> {
|
||
let i = self.active_tab;
|
||
let Some(handle) = self.attr_editor_handle else {
|
||
return Task::none();
|
||
};
|
||
// The block's layer may have been locked while the editor was open —
|
||
// refuse to write attributes to a locked-layer block.
|
||
if let Some(layer) = self.tabs[i].scene.locked_layer_name(handle) {
|
||
self.command_line.push_info(crate::tf!(
|
||
"Object is on locked layer \"{layer}\" — unlock the layer to edit its attributes."
|
||
).as_ref());
|
||
return Task::none();
|
||
}
|
||
// Snapshot the working copy so the document can be mutated while the
|
||
// dialog's rows stay put (it remains open for further edits).
|
||
let rows = self.attr_editor_rows.clone();
|
||
let block = self.attr_editor_block.clone();
|
||
|
||
self.push_undo_snapshot(i, "ATTEDIT");
|
||
let mut changed = false;
|
||
if let Some(codec::EntityType::Insert(ins)) =
|
||
self.tabs[i].scene.document.get_entity_mut(handle)
|
||
{
|
||
if ins.block_name == block && rows.len() == ins.attributes.len() {
|
||
for (attr, row) in ins.attributes.iter_mut().zip(rows.iter()) {
|
||
changed |= apply_attr_row(attr, row);
|
||
}
|
||
}
|
||
}
|
||
if changed {
|
||
self.invalidate_property_targets(i, &[handle]);
|
||
self.tabs[i].dirty = true;
|
||
} else {
|
||
// Nothing changed — drop the snapshot pushed a moment ago.
|
||
self.discard_last_undo_entry(i);
|
||
}
|
||
self.refresh_properties();
|
||
Task::none()
|
||
}
|
||
}
|
||
|
||
/// Build the editor's working copy of one attribute. Angles are shown in
|
||
/// degrees; numeric fields become strings so the user can type freely.
|
||
fn attr_row_from_entity(
|
||
a: &codec::entities::AttributeEntity,
|
||
prompts: &rustc_hash::FxHashMap<String, String>,
|
||
) -> AttrRow {
|
||
let fmt = |v: f64| format!("{v}");
|
||
AttrRow {
|
||
tag: a.tag.clone(),
|
||
prompt: prompts.get(&a.tag).cloned().unwrap_or_default(),
|
||
value: a.get_value().to_string(),
|
||
text_style: a.text_style.clone(),
|
||
height: fmt(a.height),
|
||
rotation: fmt(a.rotation.to_degrees()),
|
||
width_factor: fmt(a.width_factor),
|
||
oblique: fmt(a.oblique_angle.to_degrees()),
|
||
h_align: a.horizontal_alignment,
|
||
v_align: a.vertical_alignment,
|
||
backwards: a.text_generation_flags & 0x2 != 0,
|
||
upside_down: a.text_generation_flags & 0x4 != 0,
|
||
layer: a.common.layer.clone(),
|
||
color: a.common.color,
|
||
linetype: a.common.linetype.clone(),
|
||
line_weight: a.common.line_weight,
|
||
}
|
||
}
|
||
|
||
/// Write one edited row back onto its attribute; returns whether anything
|
||
/// changed. Numeric fields are parsed (angles from degrees); an unparseable
|
||
/// field is left as-is.
|
||
fn apply_attr_row(a: &mut codec::entities::AttributeEntity, row: &AttrRow) -> bool {
|
||
let mut ch = false;
|
||
if a.get_value() != row.value {
|
||
a.set_value(row.value.clone());
|
||
ch = true;
|
||
}
|
||
if a.text_style != row.text_style {
|
||
a.text_style = row.text_style.clone();
|
||
ch = true;
|
||
}
|
||
if let Ok(h) = row.height.trim().parse::<f64>() {
|
||
if a.height != h {
|
||
a.height = h;
|
||
ch = true;
|
||
}
|
||
}
|
||
if let Ok(deg) = row.rotation.trim().parse::<f64>() {
|
||
let rad = deg.to_radians();
|
||
if (a.rotation - rad).abs() > 1e-12 {
|
||
a.rotation = rad;
|
||
ch = true;
|
||
}
|
||
}
|
||
if let Ok(w) = row.width_factor.trim().parse::<f64>() {
|
||
if a.width_factor != w {
|
||
a.width_factor = w;
|
||
ch = true;
|
||
}
|
||
}
|
||
if let Ok(deg) = row.oblique.trim().parse::<f64>() {
|
||
let rad = deg.to_radians();
|
||
if (a.oblique_angle - rad).abs() > 1e-12 {
|
||
a.oblique_angle = rad;
|
||
ch = true;
|
||
}
|
||
}
|
||
if a.horizontal_alignment != row.h_align {
|
||
a.horizontal_alignment = row.h_align;
|
||
ch = true;
|
||
}
|
||
if a.vertical_alignment != row.v_align {
|
||
a.vertical_alignment = row.v_align;
|
||
ch = true;
|
||
}
|
||
// Text generation flags: bit 0x2 = backwards, bit 0x4 = upside down.
|
||
let new_flags = (a.text_generation_flags & !0x6)
|
||
| if row.backwards { 0x2 } else { 0 }
|
||
| if row.upside_down { 0x4 } else { 0 };
|
||
if new_flags != a.text_generation_flags {
|
||
a.text_generation_flags = new_flags;
|
||
ch = true;
|
||
}
|
||
if a.common.layer != row.layer {
|
||
a.common.layer = row.layer.clone();
|
||
ch = true;
|
||
}
|
||
if a.common.color != row.color {
|
||
a.common.color = row.color;
|
||
a.common.color_name = None;
|
||
a.common.color_book_handle = None;
|
||
ch = true;
|
||
}
|
||
if a.common.linetype != row.linetype {
|
||
a.common.linetype = row.linetype.clone();
|
||
ch = true;
|
||
}
|
||
if a.common.line_weight != row.line_weight {
|
||
a.common.line_weight = row.line_weight;
|
||
ch = true;
|
||
}
|
||
ch
|
||
}
|
||
|
||
/// Map each attribute tag to the prompt text declared on the block's matching
|
||
/// ATTDEF. Attribute instances (ATTRIB) carry only tag + value; the prompt is
|
||
/// defined once on the block definition. Tags with no definition are absent.
|
||
fn block_attr_prompts(
|
||
doc: &codec::CadDocument,
|
||
block_name: &str,
|
||
) -> rustc_hash::FxHashMap<String, String> {
|
||
let mut map = rustc_hash::FxHashMap::default();
|
||
if let Some(br) = doc.block_records.get(block_name) {
|
||
for &eh in &br.entity_handles {
|
||
if let Some(codec::EntityType::AttributeDefinition(ad)) = doc.get_entity(eh) {
|
||
map.insert(ad.tag.clone(), ad.prompt.clone());
|
||
}
|
||
}
|
||
}
|
||
map
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod layer_rename_tests {
|
||
use crate::app::OpenCADStudio;
|
||
use codec::entities::Line;
|
||
use codec::{EntityType, Handle};
|
||
|
||
fn app_with_editing_layer() -> OpenCADStudio {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
app.automation_op(r#"{"op":"new"}"#);
|
||
let _ = app.on_layer_new();
|
||
app
|
||
}
|
||
|
||
fn rename_layer(app: &mut OpenCADStudio, old_name: &str, new_name: &str) {
|
||
let i = app.active_tab;
|
||
let idx = app.tabs[i]
|
||
.layers
|
||
.layers
|
||
.iter()
|
||
.position(|layer| layer.name == old_name)
|
||
.expect("layer exists in panel");
|
||
app.tabs[i].layers.editing = Some(idx);
|
||
app.tabs[i].layers.edit_buf = new_name.to_string();
|
||
let _ = app.on_layer_rename_commit();
|
||
}
|
||
|
||
#[test]
|
||
fn layer_rename_allows_ascii_case_only_change() {
|
||
let mut app = app_with_editing_layer();
|
||
let i = app.active_tab;
|
||
let old_name = app.tabs[i].layers.edit_buf.clone();
|
||
rename_layer(&mut app, &old_name, "TEST");
|
||
let handle = app.tabs[i].scene.document.layers.get("TEST").unwrap().handle;
|
||
|
||
rename_layer(&mut app, "TEST", "test");
|
||
|
||
let layer = app.tabs[i].scene.document.layers.get("test").unwrap();
|
||
assert_eq!(layer.name, "test");
|
||
assert_eq!(layer.handle, handle);
|
||
}
|
||
|
||
#[test]
|
||
fn layer_rename_allows_unicode_case_only_change() {
|
||
let mut app = app_with_editing_layer();
|
||
let i = app.active_tab;
|
||
let old_name = app.tabs[i].layers.edit_buf.clone();
|
||
rename_layer(&mut app, &old_name, "é");
|
||
|
||
rename_layer(&mut app, "é", "É");
|
||
|
||
assert_eq!(
|
||
app.tabs[i].scene.document.layers.get("É").unwrap().name,
|
||
"É"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn current_layer_rename_updates_name_and_allocated_handle() {
|
||
let mut app = app_with_editing_layer();
|
||
let i = app.active_tab;
|
||
let idx = app.tabs[i].layers.editing.expect("new layer is being edited");
|
||
let old_name = app.tabs[i].layers.layers[idx].name.clone();
|
||
app.tabs[i]
|
||
.scene
|
||
.document
|
||
.layers
|
||
.get_mut(&old_name)
|
||
.unwrap()
|
||
.handle = Handle::NULL;
|
||
app.tabs[i].layers.selected = Some(idx);
|
||
let _ = app.on_layer_set_current();
|
||
|
||
rename_layer(&mut app, &old_name, "Renamed");
|
||
|
||
let layer = app.tabs[i]
|
||
.scene
|
||
.document
|
||
.layers
|
||
.get("Renamed")
|
||
.unwrap();
|
||
assert!(layer.handle.is_valid());
|
||
assert_eq!(app.tabs[i].active_layer, "Renamed");
|
||
assert_eq!(app.tabs[i].layers.current_layer, "Renamed");
|
||
assert_eq!(app.ribbon.active_layer, "Renamed");
|
||
assert_eq!(
|
||
app.tabs[i].scene.document.header.current_layer_name,
|
||
"Renamed"
|
||
);
|
||
assert_eq!(
|
||
app.tabs[i].scene.document.header.current_layer_handle,
|
||
layer.handle
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn layer_rename_undo_redo_restores_active_layer() {
|
||
let mut app = app_with_editing_layer();
|
||
let i = app.active_tab;
|
||
let idx = app.tabs[i].layers.editing.expect("new layer is being edited");
|
||
let old_name = app.tabs[i].layers.layers[idx].name.clone();
|
||
app.tabs[i].layers.selected = Some(idx);
|
||
let _ = app.on_layer_set_current();
|
||
|
||
rename_layer(&mut app, &old_name, "Renamed");
|
||
app.undo_active_tab();
|
||
|
||
assert!(app.tabs[i].scene.document.layers.contains(&old_name));
|
||
assert_eq!(app.tabs[i].active_layer, old_name);
|
||
assert_eq!(app.ribbon.active_layer, app.tabs[i].active_layer);
|
||
|
||
app.redo_active_tab();
|
||
|
||
assert!(app.tabs[i].scene.document.layers.contains("Renamed"));
|
||
assert_eq!(app.tabs[i].active_layer, "Renamed");
|
||
assert_eq!(app.ribbon.active_layer, "Renamed");
|
||
}
|
||
|
||
#[test]
|
||
fn layer_rename_keeps_independent_current_layer_mirrors() {
|
||
let mut app = app_with_editing_layer();
|
||
let i = app.active_tab;
|
||
let old_name = app.tabs[i].layers.edit_buf.clone();
|
||
rename_layer(&mut app, &old_name, "A");
|
||
let _ = app.on_layer_new();
|
||
let b_idx = app.tabs[i].layers.editing.expect("new layer is being edited");
|
||
app.tabs[i].layers.selected = Some(b_idx);
|
||
let _ = app.on_layer_set_current();
|
||
let header_name = app.tabs[i].scene.document.header.current_layer_name.clone();
|
||
let header_handle = app.tabs[i].scene.document.header.current_layer_handle;
|
||
app.tabs[i].active_layer = "A".to_string();
|
||
|
||
rename_layer(&mut app, "A", "Renamed");
|
||
|
||
assert_eq!(app.tabs[i].active_layer, "Renamed");
|
||
assert_eq!(
|
||
app.tabs[i].scene.document.header.current_layer_name,
|
||
header_name
|
||
);
|
||
assert_eq!(
|
||
app.tabs[i].scene.document.header.current_layer_handle,
|
||
header_handle
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn layer_rename_updates_mixed_case_entity_references() {
|
||
let mut app = app_with_editing_layer();
|
||
let i = app.active_tab;
|
||
let old_name = app.tabs[i].layers.edit_buf.clone();
|
||
rename_layer(&mut app, &old_name, "TEST");
|
||
let mut line = Line::new();
|
||
line.common.layer = "test".to_string();
|
||
let handle = app.tabs[i].scene.add_entity(EntityType::Line(line));
|
||
app.tabs[i]
|
||
.scene
|
||
.invalidate_layer_dependencies(&["TEST".to_string()]);
|
||
|
||
rename_layer(&mut app, "TEST", "Renamed");
|
||
|
||
assert_eq!(
|
||
app.tabs[i]
|
||
.scene
|
||
.document
|
||
.get_entity(handle)
|
||
.unwrap()
|
||
.common()
|
||
.layer,
|
||
"Renamed"
|
||
);
|
||
let epoch = app.tabs[i].scene.geometry_epoch;
|
||
app.tabs[i]
|
||
.scene
|
||
.invalidate_layer_dependencies(&["Renamed".to_string()]);
|
||
assert_ne!(app.tabs[i].scene.geometry_epoch, epoch);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod plot_style_ctb_guard_tests {
|
||
use crate::app::OpenCADStudio;
|
||
use codec::entities::Line;
|
||
use codec::types::Vector3;
|
||
|
||
fn line_handle(app: &mut OpenCADStudio) -> codec::Handle {
|
||
let mut line = Line::new();
|
||
line.start = Vector3::ZERO;
|
||
line.end = Vector3::new(1.0, 0.0, 0.0);
|
||
app.commit_entity_handle(codec::EntityType::Line(line))
|
||
.expect("line should commit")
|
||
}
|
||
|
||
#[test]
|
||
fn ctb_mode_plot_style_choice_pushes_no_undo_entry() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
let i = app.active_tab;
|
||
let h = line_handle(&mut app);
|
||
// Mirror `chprop_integration_tests`: seed `source_handles` so
|
||
// `property_target_handles` returns the line.
|
||
app.tabs[i].properties.source_handles = vec![h];
|
||
// CTB mode: the B15 `plot_style` arm is a no-op early return.
|
||
app.tabs[i].scene.document.header.plotstyle_mode = true;
|
||
// Settle any pending snapshot left by the fixture setup.
|
||
app.finish_pending_history(i);
|
||
assert!(
|
||
app.tabs[i].history.pending.is_none(),
|
||
"test setup: no pending snapshot before the choice change"
|
||
);
|
||
let before = app.tabs[i].history.undo_stack.len();
|
||
// Drive the handler directly: `update()` closes the pending
|
||
// transaction at the message boundary (`finish_all_pending_history`),
|
||
// which silently discards the leaked no-op snapshot and would mask
|
||
// the bug. The direct call exposes whether a snapshot was pushed.
|
||
let _ = app.on_prop_geom_choice_changed("plot_style", "ByLayer".to_string());
|
||
assert!(
|
||
app.tabs[i].history.pending.is_none(),
|
||
"CTB-mode plot_style choice is a no-op and must not push an undo snapshot"
|
||
);
|
||
app.finish_pending_history(i);
|
||
assert_eq!(
|
||
app.tabs[i].history.undo_stack.len(),
|
||
before,
|
||
"CTB-mode plot_style choice must not add an undo entry"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Reproductions for the stale-tab-index crashes: iced dispatches every
|
||
/// message produced from one view snapshot before rebuilding it, so a second
|
||
/// `TabClose` (or a `PendingClose::Tab` stashed while the unsaved dialog is
|
||
/// open) can carry an index the first removal already invalidated.
|
||
#[cfg(test)]
|
||
mod tab_close_tests {
|
||
use crate::app::{Message, OpenCADStudio};
|
||
|
||
/// `[Start, Drawing A, Drawing B]` — three clean tabs.
|
||
fn three_tabs() -> OpenCADStudio {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
app.push_test_document();
|
||
app.push_test_document();
|
||
assert_eq!(app.tabs.len(), 3, "start page plus two drawings");
|
||
assert!(!app.tabs[1].is_start && !app.tabs[2].is_start);
|
||
app
|
||
}
|
||
|
||
/// Two close messages built from the same view snapshot: the first one
|
||
/// removes the tab, the second carries the now-outdated reference. The
|
||
/// close request must degrade to a no-op, not panic.
|
||
#[test]
|
||
fn stale_tab_close_is_a_no_op() {
|
||
let mut app = three_tabs();
|
||
let id = app.tabs[app.tabs.len() - 1].id;
|
||
|
||
let _ = app.update(Message::TabClose(id));
|
||
assert_eq!(app.tabs.len(), 2, "first close removed the tab");
|
||
|
||
// Same target again, as the batched second message from one snapshot.
|
||
let _ = app.update(Message::TabClose(id));
|
||
assert_eq!(
|
||
app.tabs.len(),
|
||
2,
|
||
"stale close must not remove another tab or panic"
|
||
);
|
||
}
|
||
|
||
/// Double-clicking one tab's × in a single batch must close that tab
|
||
/// exactly once; the second message must not slide onto the tab that
|
||
/// shifted into its place.
|
||
#[test]
|
||
fn double_close_from_one_snapshot_closes_only_the_clicked_tab() {
|
||
let mut app = three_tabs();
|
||
let first_id = app.tabs[1].id;
|
||
let other_id = app.tabs[2].id;
|
||
|
||
let _ = app.update(Message::TabClose(first_id));
|
||
let _ = app.update(Message::TabClose(first_id));
|
||
|
||
assert!(
|
||
app.tabs.iter().all(|t| t.id != first_id),
|
||
"the clicked tab must be closed"
|
||
);
|
||
assert!(
|
||
app.tabs.iter().any(|t| t.id == other_id),
|
||
"the tab that shifted into index 1 must survive"
|
||
);
|
||
}
|
||
|
||
/// The unsaved dialog stashes a close target while it is open. Another
|
||
/// close landing before the user answers shifts the vector, so Discard
|
||
/// must still discard the tab the dialog was opened for.
|
||
#[test]
|
||
fn unsaved_dialog_discard_closes_the_tab_it_was_opened_for() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
app.push_test_document(); // A
|
||
app.push_test_document(); // B
|
||
app.push_test_document(); // C
|
||
assert_eq!(app.tabs.len(), 4);
|
||
let a_id = app.tabs[1].id;
|
||
let b_id = app.tabs[2].id;
|
||
let c_id = app.tabs[3].id;
|
||
app.tabs[2].dirty = true;
|
||
|
||
// Close dirty B: opens the dialog and stashes the pending target.
|
||
let _ = app.update(Message::TabClose(b_id));
|
||
assert!(
|
||
app.pending_close.is_some(),
|
||
"dirty close opens the unsaved dialog"
|
||
);
|
||
|
||
// A second close lands while the dialog is pending (batched message /
|
||
// automation `close` / save continuation) and shifts indices.
|
||
let _ = app.update(Message::TabClose(a_id));
|
||
assert!(app.tabs.iter().all(|t| t.id != a_id));
|
||
|
||
// User picks Discard.
|
||
let _ = app.update(Message::UnsavedDialogDiscard);
|
||
|
||
assert!(
|
||
app.tabs.iter().all(|t| t.id != b_id),
|
||
"the dirty tab the dialog was opened for must be discarded"
|
||
);
|
||
assert!(
|
||
app.tabs.iter().any(|t| t.id == c_id),
|
||
"C must survive the stale pending index"
|
||
);
|
||
assert!(app.pending_close.is_none(), "pending target consumed");
|
||
assert!(app.active_tab < app.tabs.len(), "active tab stays valid");
|
||
}
|
||
}
|