cad-editor/src/app/update.rs
Michael Flynn e46701456f Add plugin host architecture and Storm Sewer add-on package
Generic plugin runtime (src/plugin/, HostSession, per-tab plugin_state, command router). QGIS-style add-on layout with plugin.toml and single PluginRegistration. Storm Sewer refactored as reference consumer with dispatch.rs, catchments, LandXML, headless tests (43 passing). Core update.rs decoupled from storm_sewer via CadCommand object-pick hooks. Docs: plugin-architecture.md, plugin-template, PR and issue-78 reply drafts.
2026-06-09 13:41:21 -04:00

8109 lines
366 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use super::helpers::{
ortho_constrain, parse_coord, polar_constrain, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis,
CoordKind,
};
use super::{Message, OpenCADStudio, POLY_START_DELAY_MS};
use crate::modules::ModuleEvent;
use crate::scene::grip::{find_hit_grip, find_hit_grip_paper, GripEdit};
use crate::scene::object::GripApply;
use crate::scene::{
self, hover_id, Scene, TileEdgeOrient, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX,
};
use crate::ui::PropertiesPanel;
use acadrust::types::Color as AcadColor;
use acadrust::{EntityType as AcadEntityType, Handle};
use iced::time::Instant;
use iced::window;
use iced::{mouse, Task};
const VIEWCUBE_HIT_SIZE: f32 = VIEWCUBE_DRAW_PX;
/// Pixel distance from a Model-tile inner divider that still registers as
/// a resize grip on the press.
const TILE_EDGE_HIT_PX: f32 = 4.0;
/// Normalized minimum tile size before `collapse_small_model_tiles` merges
/// it into a neighbour. Sized to comfortably contain the ViewCube + its
/// padding so a tile that's still GPU-rendered always has room to show
/// the gizmo.
fn tile_min_norm(canvas_w: f32, canvas_h: f32) -> (f32, f32) {
let px = VIEWCUBE_DRAW_PX + 2.0 * VIEWCUBE_PAD + 16.0;
(
(px / canvas_w.max(1.0)).min(0.95),
(px / canvas_h.max(1.0)).min(0.95),
)
}
fn format_size(bytes: u64) -> String {
const KB: f64 = 1024.0;
const MB: f64 = KB * 1024.0;
const GB: f64 = MB * 1024.0;
let b = bytes as f64;
if b >= GB {
format!("{:.2} GB", b / GB)
} else if b >= MB {
format!("{:.1} MB", b / MB)
} else if b >= KB {
format!("{:.1} KB", b / KB)
} else {
format!("{bytes} B")
}
}
impl OpenCADStudio {
pub fn update(&mut self, msg: Message) -> Task<Message> {
match msg {
Message::Tick(t) => {
let i = self.active_tab;
self.tabs[i].scene.update(t - self.start);
// If the camera moved since we last synced, write it back to
// the document and mark the file dirty.
let gen = self.tabs[i].scene.camera_generation;
if gen != self.tabs[i].last_synced_camera_gen {
self.tabs[i].last_synced_camera_gen = gen;
if self.tabs[i].scene.sync_camera_to_document() {
self.tabs[i].dirty = true;
}
}
Task::none()
}
Message::OpenFile => {
Task::perform(crate::io::pick_open_path(), Message::OpenPathPicked)
}
Message::OpenPathPicked(None) => Task::none(),
Message::OpenRecent(path) => {
// Recents are read from disk every save → the path may be
// stale. Skip silently if the file no longer exists; the
// entry stays in the list so the user can clean it up.
match std::fs::metadata(&path) {
Ok(m) => self.update(Message::OpenPathPicked(Some((path, m.len())))),
Err(_) => {
self.command_line.push_error(&format!(
"Recent file no longer exists: {}",
path.display()
));
Task::none()
}
}
}
Message::RecentRemove(path) => {
self.app_menu.remove_recent(&path);
Task::none()
}
Message::OpenPathPicked(Some((path, size_bytes))) => {
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "unknown".into());
let phase =
std::sync::Arc::new(std::sync::atomic::AtomicU8::new(super::OPEN_PHASE_READING));
self.opening = Some(super::OpenProgress {
name: name.clone(),
size_bytes,
phase: phase.clone(),
started: Instant::now(),
});
let size_label = format_size(size_bytes);
self.command_line
.push_info(&format!("Opening \"{name}\" ({size_label})…"));
Task::perform(
crate::io::open_path_with_phase(path, phase),
Message::FileOpened,
)
}
Message::OpenCancel => {
if let Some(p) = self.opening.take() {
self.command_line
.push_info(&format!("Open cancelled: \"{}\"", p.name));
}
Task::none()
}
Message::FileOpened(Ok((name, path, doc, caches))) => {
// If the user clicked Cancel while the parser was running, the
// overlay state was cleared and we silently drop the result.
if self.opening.is_none() {
return Task::none();
}
let open_started = self.opening.take().map(|p| p.started);
let timings = caches.timings;
let entity_count = doc.entities().count();
self.command_line
.push_output(&format!("Opened \"{name}\"{entity_count} entities"));
if caches.corrupt_dropped > 0 {
self.command_line.push_error(&format!(
"Warning: {} corrupt entities dropped (parser junk — bad normals / counts)",
caches.corrupt_dropped
));
}
self.app_menu.push_recent(path.clone());
let current_is_empty = {
let t = &self.tabs[self.active_tab];
!t.is_start
&& t.current_path.is_none()
&& !t.dirty
&& self.tabs[self.active_tab].scene.document.entities().count() == 0
};
let i = if current_is_empty {
self.active_tab
} else {
self.tab_counter += 1;
let new_tab = super::document::DocumentTab::new_drawing(self.tab_counter);
self.tabs.push(new_tab);
let idx = self.tabs.len() - 1;
self.active_tab = idx;
idx
};
self.tabs[i].current_path = Some(path.clone());
self.tabs[i].scene.document = doc;
// Current model-space annotation scale comes from the drawing's
// CANNOSCALEVALUE (paper/drawing factor); the multiplier we use
// for text/dim sizing is its inverse (1:50 -> 0.02 -> 50.0).
let cannoscale_value = self.tabs[i].scene.document.header.annotation_scale_value;
self.tabs[i].scene.annotation_scale =
if cannoscale_value > 1e-9 { (1.0 / cannoscale_value) as f32 } else { 1.0 };
// Auto-resolve XREFs relative to the opened file's directory.
let mut xref_ms = 0u32;
if let Some(base_dir) = path.parent() {
// xref content arrives un-purged: parser-garbage entities
// inside the referenced file can trigger infinite loops in
// tessellation. `resolve_xrefs` runs the corrupt-entity
// guard inline as it merges each xref, so no second
// full-document walk is needed here.
let t_xref = Instant::now();
let (xrefs, extra_dropped) =
crate::io::xref::resolve_xrefs(&mut self.tabs[i].scene.document, base_dir);
xref_ms = t_xref.elapsed().as_millis() as u32;
if extra_dropped > 0 {
self.command_line.push_error(&format!(
"Warning: {extra_dropped} corrupt xref entities dropped"
));
}
for info in &xrefs {
match info.status {
crate::io::xref::XrefStatus::Loaded => {
self.command_line
.push_output(&format!("XREF Loaded \"{}\"", info.name));
}
crate::io::xref::XrefStatus::NotFound => {
self.command_line.push_error(&format!(
"XREF Not found: \"{}\" ({})",
info.name, info.path
));
}
crate::io::xref::XrefStatus::Unloaded => {
self.command_line.push_info(&format!(
"XREF Unloaded (skipped): \"{}\"",
info.name
));
}
}
}
}
// Open-time breakdown so regressions are visible immediately.
// `total` is wall time from the Open click to here (post-xref,
// pre-first-frame); the phase figures are the background-thread
// parse/purge/cache spans plus the UI-thread xref resolve.
let total_ms = open_started
.map(|s| s.elapsed().as_millis() as u32)
.unwrap_or(0);
self.command_line.push_info(&format!(
" parse {}ms · purge {}ms · caches {}ms · xref {}ms · total {}ms",
timings.parse_ms, timings.purge_ms, timings.caches_ms, xref_ms, total_ms
));
// Caches were built on the background thread inside open_path().
self.tabs[i].scene.world_offset = caches.world_offset;
self.tabs[i].scene.local_extent_max = caches.local_extent_max;
self.tabs[i].scene.hatches = caches.hatches;
self.tabs[i].scene.images = caches.images;
self.tabs[i].scene.meshes = caches.meshes;
// Invalidate the wire cache so the new document is tessellated.
self.tabs[i].scene.bump_geometry();
self.tabs[i].scene.selected = rustc_hash::FxHashSet::default();
self.tabs[i].scene.preview_wires = vec![];
self.tabs[i].scene.current_layout = "Model".to_string();
crate::linetypes::populate_document(&mut self.tabs[i].scene.document);
self.tabs[i].properties = PropertiesPanel::empty();
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.sync_ribbon_layers();
// Reset the Home-ribbon Color / Linetype / Lineweight chips
// to the newly opened document's CECOLOR / CELTYPE / CELWEIGHT
// defaults (or to ByLayer when the file leaves them empty).
// Without this they stick to whatever the prior tab had
// selected — see #21.
self.sync_ribbon_from_selection();
self.tabs[i].scene.restore_saved_camera();
self.sync_render_mode_to_active_tile(i);
self.tabs[i].last_synced_camera_gen = self.tabs[i].scene.camera_generation;
self.tabs[i].dirty = false;
self.tabs[i].history = super::document::HistoryState::default();
self.refresh_selected_grips();
Task::none()
}
Message::FileOpened(Err(e)) => {
// If the user cancelled, the overlay was already cleared and
// we suppress the noise.
let was_open = self.opening.take().is_some();
if was_open && e != "Cancelled" {
self.command_line.push_error(&format!("Open failed: {e}"));
}
Task::none()
}
Message::ImagePick => {
Task::perform(crate::io::pick_image_file(), Message::ImagePickResult)
}
Message::ImagePickResult(Ok((path, pw, ph))) => {
use crate::command::CadCommand;
use crate::modules::home::draw::raster_image::ImageCommand;
let path_str = path.to_string_lossy().into_owned();
let short = std::path::Path::new(&path_str)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(&path_str)
.to_string();
self.command_line
.push_output(&format!("IMAGE \"{short}\": {pw}×{ph} px"));
let cmd = ImageCommand::new(path_str, pw, ph);
let i = self.active_tab;
self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd));
Task::none()
}
Message::ImagePickResult(Err(e)) => {
if e != "Cancelled" {
self.command_line.push_error(&format!("IMAGE: {e}"));
}
Task::none()
}
Message::XAttachPick => Task::perform(
async {
let handle = rfd::AsyncFileDialog::new()
.set_title("Select External Reference File")
.add_filter("CAD Files", &["dwg", "dxf", "DWG", "DXF"])
.add_filter("DWG Files", &["dwg", "DWG"])
.add_filter("DXF Files", &["dxf", "DXF"])
.pick_file()
.await;
match handle {
Some(h) => Ok(h.path().to_path_buf()),
None => Err("Cancelled".to_string()),
}
},
Message::XAttachPickResult,
),
Message::XAttachPickResult(Ok(path)) => {
use crate::command::CadCommand;
use crate::modules::insert::xattach::XAttachCommand;
let path_str = path.to_string_lossy().into_owned();
let cmd = XAttachCommand::with_path(path_str);
let i = self.active_tab;
self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd));
Task::none()
}
Message::XAttachPickResult(Err(e)) => {
if e != "Cancelled" {
self.command_line.push_error(&format!("XATTACH: {e}"));
}
Task::none()
}
Message::WblockSave(block_name) => {
let name = block_name.clone();
Task::perform(
async move {
let path = rfd::AsyncFileDialog::new()
.set_title("Save Block As")
.set_file_name("block.dwg")
.add_filter("DWG Files", &["dwg"])
.save_file()
.await
.map(|h| h.path().to_path_buf());
(name, path)
},
|(name, path)| Message::WblockSaveResult(name, path),
)
}
Message::WblockSaveResult(block_name, Some(path)) => {
let i = self.active_tab;
let result = if block_name == "*" {
let handles: Vec<_> = self.tabs[i].scene.selected.iter().copied().collect();
crate::modules::insert::wblock::extract_entities_to_doc(
&self.tabs[i].scene.document,
&handles,
)
} else {
crate::modules::insert::wblock::extract_block_to_doc(
&self.tabs[i].scene.document,
&block_name,
)
};
match result {
Ok(doc) => match crate::io::save(&doc, &path) {
Ok(()) => {
let fname = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string_lossy().into_owned());
self.command_line.push_output(&format!(
"WBLOCK Saved \"{block_name}\"\"{fname}\""
));
}
Err(e) => self
.command_line
.push_error(&format!("WBLOCK save failed: {e}")),
},
Err(e) => self.command_line.push_error(&format!("WBLOCK: {e}")),
}
Task::none()
}
Message::WblockSaveResult(_, None) => Task::none(),
Message::DataExtractionSave(csv) => {
let csv_clone = csv.clone();
Task::perform(
async move {
let path = rfd::AsyncFileDialog::new()
.set_title("Save Data Extraction")
.set_file_name("extraction.csv")
.add_filter("CSV", &["csv"])
.add_filter("All Files", &["*"])
.save_file()
.await
.map(|h| h.path().to_path_buf());
(csv_clone, path)
},
|(csv, path)| Message::DataExtractionSaveResult(csv, path),
)
}
Message::DataExtractionSaveResult(csv, Some(path)) => {
match std::fs::write(&path, csv.as_bytes()) {
Ok(()) => {
let rows = csv.lines().count().saturating_sub(1);
let fname = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string_lossy().into_owned());
self.command_line
.push_output(&format!("DATAEXTRACTION {rows} rows → \"{fname}\""));
}
Err(e) => self
.command_line
.push_error(&format!("DATAEXTRACTION: write failed: {e}")),
}
Task::none()
}
Message::DataExtractionSaveResult(_, None) => Task::none(),
Message::StlExport => {
let i = self.active_tab;
if self.tabs[i].scene.meshes.is_empty() {
self.command_line
.push_error("STLOUT: no 3D mesh data in this drawing.");
return Task::none();
}
Task::perform(
async {
rfd::AsyncFileDialog::new()
.set_title("Export STL")
.set_file_name("export.stl")
.add_filter("STL Files", &["stl"])
.add_filter("All Files", &["*"])
.save_file()
.await
.map(|h| h.path().to_path_buf())
},
Message::StlExportPath,
)
}
Message::StlExportPath(Some(path)) => {
// Re-build STL bytes (we can't easily pass them through the message).
let i = self.active_tab;
// STL gets the highest-resolution LOD (slot 0) so the
// exported geometry isn't downgraded by the view-dependent
// mesh LOD ladder used for rendering.
let meshes: Vec<crate::scene::mesh_model::MeshModel> = self.tabs[i]
.scene
.meshes
.values()
.filter_map(|s| s.lods.first().cloned())
.collect();
let mesh_refs: Vec<&crate::scene::mesh_model::MeshModel> = meshes.iter().collect();
match crate::io::stl::build_stl(&mesh_refs) {
Some(bytes) => match std::fs::write(&path, bytes) {
Ok(()) => self
.command_line
.push_output(&format!("STLOUT: exported to \"{}\"", path.display())),
Err(e) => self
.command_line
.push_error(&format!("STLOUT: write error: {e}")),
},
None => self
.command_line
.push_error("STLOUT: no mesh data to export."),
}
Task::none()
}
Message::StlExportPath(None) => Task::none(),
// ── STEP AP203 export ─────────────────────────────────────────
Message::StepExport => {
let i = self.active_tab;
if self.tabs[i].scene.meshes.is_empty() {
self.command_line
.push_error("STEPOUT: no 3D mesh data in this drawing.");
return Task::none();
}
Task::perform(
async {
rfd::AsyncFileDialog::new()
.set_title("Export STEP AP203")
.set_file_name("export.step")
.add_filter("STEP Files", &["step", "stp"])
.add_filter("All Files", &["*"])
.save_file()
.await
.map(|h| h.path().to_path_buf())
},
Message::StepExportPath,
)
}
Message::StepExportPath(Some(path)) => {
let i = self.active_tab;
// Export uses LOD 0 (full resolution); see StlExportPath above.
let meshes: Vec<crate::scene::mesh_model::MeshModel> = self.tabs[i]
.scene
.meshes
.values()
.filter_map(|s| s.lods.first().cloned())
.collect();
let mesh_refs: Vec<&crate::scene::mesh_model::MeshModel> = meshes.iter().collect();
match crate::io::step::build_step(&mesh_refs) {
Some(text) => match std::fs::write(&path, text.as_bytes()) {
Ok(()) => self
.command_line
.push_output(&format!("STEPOUT: exported to \"{}\"", path.display())),
Err(e) => self
.command_line
.push_error(&format!("STEPOUT: write error: {e}")),
},
None => self
.command_line
.push_error("STEPOUT: no mesh data to export."),
}
Task::none()
}
Message::StepExportPath(None) => Task::none(),
// ── OBJ import ────────────────────────────────────────────────
Message::ObjImport => Task::perform(
async {
rfd::AsyncFileDialog::new()
.set_title("Import OBJ Mesh")
.add_filter("Wavefront OBJ", &["obj", "OBJ"])
.add_filter("All Files", &["*"])
.pick_file()
.await
.map(|h| h.path().to_path_buf())
},
Message::ObjImportPath,
),
Message::ObjImportPath(Some(path)) => {
let src = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(e) => {
self.command_line
.push_error(&format!("IMPORTOBJ: read error: {e}"));
return Task::none();
}
};
let color = [0.7f32, 0.7, 0.85, 1.0];
match crate::io::obj::parse_obj(&src, color) {
None => {
self.command_line
.push_error("IMPORTOBJ: no usable geometry in file.");
}
Some(mut mesh) => {
let i = self.active_tab;
let file_stem = path
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "obj_mesh".into());
mesh.name = file_stem.clone();
self.push_undo_snapshot(i, "IMPORTOBJ");
use crate::modules::insert::solid3d_cmds::empty_solid3d;
let entity = empty_solid3d();
let handle = self.tabs[i].scene.add_entity(entity);
if !handle.is_null() {
self.tabs[i]
.scene
.meshes
.insert(handle, crate::scene::MeshLodSet::from_single(mesh));
self.tabs[i].dirty = true;
self.command_line.push_output(&format!(
"IMPORTOBJ: imported \"{}\" as mesh.",
file_stem
));
}
}
}
Task::none()
}
Message::ObjImportPath(None) => Task::none(),
Message::SaveFile => {
let i = self.active_tab;
if let Some(path) = &self.tabs[i].current_path {
let path = path.clone();
self.tabs[i].scene.document.header.user_real1 =
self.tabs[i].scene.annotation_scale as f64;
match crate::io::save(&self.tabs[i].scene.document, &path) {
Ok(()) => {
self.command_line
.push_output(&format!("Saved: {}", path.display()));
self.tabs[i].dirty = false;
}
Err(e) => self.command_line.push_error(&format!("Save failed: {e}")),
}
Task::none()
} else {
self.save_dialog_for_unsaved = false;
self.open_save_dialog_window(i)
}
}
Message::SaveAs => {
let i = self.active_tab;
self.save_dialog_for_unsaved = false;
self.open_save_dialog_window(i)
}
Message::SaveDialogFormatChanged(fmt) => {
let (ext, _) = crate::io::parse_save_format(&fmt);
let stem = std::path::Path::new(&self.save_dialog_filename)
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "drawing".to_string());
self.save_dialog_filename = format!("{stem}.{ext}");
self.save_dialog_format = fmt;
Task::none()
}
Message::SaveDialogFilenameChanged(name) => {
self.save_dialog_filename = name;
Task::none()
}
Message::SaveDialogNavigate(path) => {
self.save_dialog_folder = path.clone();
self.save_dialog_entries = crate::io::read_dir_entries(&path);
Task::none()
}
Message::SaveDialogEntryClicked(path, is_dir) => {
if is_dir {
self.save_dialog_folder = path.clone();
self.save_dialog_entries = crate::io::read_dir_entries(&path);
} else {
// Fill filename from clicked file.
if let Some(name) = path.file_name() {
self.save_dialog_filename = name.to_string_lossy().into_owned();
}
}
Task::none()
}
Message::SaveDialogConfirm => {
let path = self.save_dialog_folder.join(&self.save_dialog_filename);
let (_, version) = crate::io::parse_save_format(&self.save_dialog_format);
let close = self.close_save_dialog_window();
let i = self.active_tab;
sync_annotation_scale_header(&mut self.tabs[i].scene);
match crate::io::save_as_version(&self.tabs[i].scene.document, &path, version) {
Ok(()) => {
self.command_line
.push_output(&format!("Saved: {}", path.display()));
self.tabs[i].current_path = Some(path.clone());
self.tabs[i].dirty = false;
if self.save_dialog_for_unsaved {
let next = self.update(Message::UnsavedPickedSavePath(Some(path)));
return Task::batch([close, next]);
}
}
Err(e) => self.command_line.push_error(&format!("Save failed: {e}")),
}
close
}
Message::SaveDialogCancel => self.close_save_dialog_window(),
Message::ClearScene => {
let i = self.active_tab;
self.push_undo_snapshot(i, "CLEAR");
self.tabs[i].scene.clear();
crate::linetypes::populate_document(&mut self.tabs[i].scene.document);
self.tabs[i].properties = PropertiesPanel::empty();
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.command_line
.push_output("Scene cleared. Standard linetypes loaded.");
self.tabs[i].current_path = None;
self.tabs[i].dirty = true;
self.sync_ribbon_layers();
Task::none()
}
Message::SetWireframe(w) => {
// Back-compat shim: forward to the new render-mode path so
// the ribbon button + WIREFRAME / SOLID command line still
// work without duplicating the rendering plumbing.
let mode = if w {
acadrust::entities::ViewportRenderMode::Wireframe2D
} else {
acadrust::entities::ViewportRenderMode::FlatShaded
};
Task::done(Message::SetRenderMode(mode))
}
Message::SetRenderMode(mode) => {
use acadrust::entities::ViewportRenderMode as M;
let i = self.active_tab;
let label = match mode {
M::Wireframe2D => "Wireframe 2D",
M::Wireframe3D => "Wireframe 3D",
M::HiddenLine => "Hidden Line",
M::FlatShaded => "Flat Shaded",
M::GouraudShaded => "Gouraud Shaded",
M::FlatShadedWithEdges => "Flat Shaded + Edges",
M::GouraudShadedWithEdges => "Gouraud Shaded + Edges",
};
// In a paper layout with an active (double-clicked)
// viewport, the picker drives that viewport entity's own
// render mode; the model-layout tab style is untouched.
if self.tabs[i].scene.set_active_viewport_render_mode(mode) {
self.tabs[i].scene.bump_geometry();
self.command_line
.push_output(&format!("Viewport visual style: {label}"));
return Task::none();
}
self.tabs[i].render_mode = mode;
// Write the style onto the active Model tile alone so it
// sticks when that tile loses focus and the other tiles keep
// their own styles.
self.tabs[i].scene.set_active_model_tile_render_mode(mode);
// Keep the legacy `wireframe` bool synced — both wireframe
// modes set it, everything else clears it.
let wf = matches!(mode, M::Wireframe2D | M::Wireframe3D);
self.tabs[i].wireframe = wf;
self.ribbon.set_wireframe(wf);
self.tabs[i].visual_style = label.into();
// Re-upload face3d fills on the next frame — the render
// pipeline keys its upload cache off `geometry_epoch`.
self.tabs[i].scene.bump_geometry();
self.command_line
.push_output(&format!("Visual style: {label}"));
Task::none()
}
Message::SetProjection(ortho) => {
use crate::scene::Projection;
let proj = if ortho {
Projection::Orthographic
} else {
Projection::Perspective
};
let i = self.active_tab;
self.tabs[i].scene.camera.borrow_mut().projection = proj;
self.tabs[i].scene.camera_generation += 1;
self.ribbon.set_ortho(ortho);
self.command_line.push_output(if ortho {
"Projection: Orthographic"
} else {
"Projection: Perspective"
});
Task::none()
}
Message::RibbonSelectTab(idx) => {
self.ribbon.select(idx);
Task::none()
}
Message::RibbonToolClick { tool_id, event } => {
self.ribbon.activate_tool(&tool_id);
match event {
ModuleEvent::Command(cmd) => return self.dispatch_command(&cmd),
ModuleEvent::OpenFileDialog => {
self.command_line
.push_info("Open DWG/DXF: not yet implemented.");
}
ModuleEvent::ClearModels => {
let i = self.active_tab;
self.tabs[i].scene.clear();
self.tabs[i].properties = PropertiesPanel::empty();
self.command_line.push_output("Scene cleared.");
}
ModuleEvent::SetWireframe(w) => {
let i = self.active_tab;
self.tabs[i].wireframe = w;
self.ribbon.set_wireframe(w);
self.tabs[i].visual_style = if w {
"Wireframe".into()
} else {
"Shaded".into()
};
self.command_line.push_output(if w {
"Visual style: Wireframe"
} else {
"Visual style: Shaded"
});
}
ModuleEvent::ToggleLayers => {
return Task::done(Message::ToggleLayers);
}
}
Task::none()
}
// ── Application menu ──────────────────────────────────────────
Message::ToggleAppMenu => {
self.app_menu.toggle();
Task::none()
}
Message::CloseAppMenu => {
self.app_menu.close();
Task::none()
}
Message::CloseAppMenuAndRun(cmd) => {
self.app_menu.close();
self.dispatch_command(&cmd.clone())
}
Message::AppMenuSearch(s) => {
self.app_menu.search = s;
Task::none()
}
// ── Document tabs ─────────────────────────────────────────────
Message::TabNew => {
self.tab_counter += 1;
let new_tab = super::document::DocumentTab::new_drawing(self.tab_counter);
self.tabs.push(new_tab);
self.active_tab = self.tabs.len() - 1;
self.sync_ribbon_layers();
// #21: reset ribbon Color / Linetype / Lineweight to the
// fresh tab's defaults (ByLayer) instead of inheriting the
// previous tab's last selection.
self.sync_ribbon_from_selection();
Task::none()
}
Message::TabSwitch(idx) => {
if idx < self.tabs.len() {
self.active_tab = idx;
self.sync_ribbon_layers();
// #21: also re-seed ribbon Color / Linetype / Lineweight
// from the newly active tab so they reflect that doc's
// CECOLOR / CELTYPE / CELWEIGHT (or its current selection
// if there is one), not the prior tab's choice.
self.sync_ribbon_from_selection();
}
Task::none()
}
Message::TabClose(idx) => {
// 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();
}
if self.tabs.get(idx).map_or(false, |t| t.dirty) {
self.pending_close = Some(super::PendingClose::Tab(idx));
return self.open_unsaved_dialog_window();
}
// 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] =
super::document::DocumentTab::new_drawing(self.tab_counter);
self.active_tab = 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_from_selection();
Task::none()
}
Message::CommandInput(s) => {
// Space submits the current input the same way Enter does
// (CAD convention) — unless the active command is collecting
// free-form text (TEXT / MTEXT / DDEDIT / attribute value
// prompts) where Space must reach the buffer as a literal
// character. `wants_text_with_spaces()` flags those prompts.
let i = self.active_tab;
let allow_literal_space = self
.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.wants_text_input() && c.wants_text_with_spaces())
.unwrap_or(false);
if !allow_literal_space && s.ends_with(' ') {
self.command_line.input = s.trim_end_matches(' ').to_string();
return Task::done(Message::CommandSubmit);
}
self.command_line.input = s;
// Typing invalidates the previous arrow-key cursor —
// the matches list has likely changed.
self.command_line.autocomplete_cursor = None;
Task::none()
}
Message::CommandAppendChar(s) => {
// 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;
return self.focus_cmd_input();
}
// While dynamic input is showing fields, numeric
// glyphs edit the focused field instead of the
// command line. Letters still go to the command line
// so command-option keywords keep working.
let numeric = !s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_digit() || matches!(c, '.' | '-' | '+'));
if numeric && 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);
} else {
self.command_line.input.push_str(&s);
}
}
self.command_line.autocomplete_cursor = None;
self.focus_cmd_input()
}
Message::CommandBackspace => {
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;
}
return self.focus_cmd_input();
}
}
self.command_line.input.pop();
self.command_line.autocomplete_cursor = None;
self.focus_cmd_input()
}
Message::DynTabNext if self.grip_popup.is_some() => {
if let Some(popup) = self.grip_popup.as_mut() {
if !popup.items.is_empty() {
popup.selected = (popup.selected + 1) % popup.items.len();
}
}
Task::none()
}
Message::DynTabNext => {
let i = self.active_tab;
let n = self.tabs[i].dyn_fields.len();
if n > 0 {
self.tabs[i].dyn_active = (self.tabs[i].dyn_active + 1) % n;
}
self.focus_cmd_input()
}
Message::SplitModelViewport(horizontal) => {
let i = self.active_tab;
self.tabs[i].scene.split_active_model_tile(horizontal);
self.tabs[i].scene.camera_generation += 1;
Task::none()
}
Message::CommandHistoryPrev => {
// Grip popup wins first — arrow keys walk its items.
if let Some(popup) = self.grip_popup.as_mut() {
if !popup.items.is_empty() {
popup.selected = if popup.selected == 0 {
popup.items.len() - 1
} else {
popup.selected - 1
};
}
return Task::none();
}
// While autocomplete is showing suggestions, ↑ walks up
// that list. Otherwise it falls back to recall history.
let i = self.active_tab;
if self.tabs[i].active_cmd.is_none()
&& self.command_line.autocomplete_prev()
{
return Task::none();
}
self.command_line.history_prev();
Task::none()
}
Message::CommandHistoryNext => {
if let Some(popup) = self.grip_popup.as_mut() {
if !popup.items.is_empty() {
popup.selected = (popup.selected + 1) % popup.items.len();
}
return Task::none();
}
let i = self.active_tab;
if self.tabs[i].active_cmd.is_none()
&& self.command_line.autocomplete_next()
{
return Task::none();
}
self.command_line.history_next();
Task::none()
}
Message::CommandHistoryToggle => {
self.command_line.toggle_history();
Task::none()
}
Message::CommandSuggestionPick(cmd) => {
self.command_line.input.clear();
self.command_line.close_history();
self.dispatch_command(&cmd)
}
Message::CommandSubmit => {
// 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();
// Grip-menu value prompt — consume the typed number and
// route it through `apply_grip_menu_value`.
if let Some(pending) = self.grip_pending.take() {
let raw = self.command_line.input.trim().to_string();
self.command_line.input.clear();
let Ok(v) = raw.parse::<f64>() else {
self.command_line.push_error(&format!(
"{}: expected a number, got \"{raw}\"",
pending.label
));
return Task::none();
};
let i = self.active_tab;
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,
);
}
self.tabs[i].scene.bump_geometry();
self.tabs[i].dirty = true;
self.refresh_selected_grips();
self.refresh_properties();
return Task::none();
}
// 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}"));
}
// If the user navigated the autocomplete list with the
// arrow keys, Enter dispatches the highlighted command
// rather than the partial text actually in the buffer.
let i_tab = self.active_tab;
if self.tabs[i_tab].active_cmd.is_none() {
if let Some(picked) = self.command_line.selected_suggestion() {
let cmd = picked.to_string();
self.command_line.input.clear();
self.command_line.autocomplete_cursor = None;
return self.dispatch_command(&cmd);
}
}
let i = self.active_tab;
// 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() {
let text = self.command_line.input.trim().to_string();
self.command_line.input.clear();
if self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.wants_text_input())
.unwrap_or(false)
{
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);
}
let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt());
if let Some(p) = prompt {
self.command_line.push_info(&p);
}
let pt = self.tabs[i].last_cursor_world;
let previews = self.tabs[i]
.active_cmd
.as_mut()
.map(|c| c.on_preview_wires(pt))
.unwrap_or_default();
self.tabs[i].scene.set_preview_wires(previews);
return self.focus_cmd_input();
}
if text.is_empty() {
let result = self.tabs[i].active_cmd.as_mut().map(|c| c.on_enter());
if let Some(r) = result {
return self.apply_cmd_result(r);
}
return Task::none();
}
if let Some((coord, kind)) = parse_coord(&text) {
// Resolve relative vs absolute. `@` forces relative,
// `#` forces absolute, no prefix follows DYN: when
// dynamic input is on, bare coordinates are relative
// to the last point (issue #26).
// `@` forces relative, `#` forces absolute, no
// prefix follows DYN (on → relative). The very
// first point has no reference, so relative falls
// back to absolute.
let want_relative = match kind {
CoordKind::Relative => true,
CoordKind::Absolute => false,
CoordKind::Default => self.dyn_input,
};
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,
}
}
};
self.last_point = Some(wcs_pt);
self.dyn_user_reshaped = false;
self.sync_dyn_fields();
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();
}
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);
}
self.command_line.push_error(&format!(
"Expected coordinates (x,y) or a number, got: \"{text}\""
));
return self.focus_cmd_input();
}
if let Some(cmd) = self.command_line.submit() {
return self.dispatch_command(&cmd);
}
// 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()
}
Message::CommandSpace => {
// Space is a literal space inside the MText preview; otherwise
// it finalises the active command like Enter.
if self.mtext_editor.as_ref().is_some_and(|e| e.show_preview) {
self.mtext_type(" ");
return Task::none();
}
return self.update(Message::CommandFinalize);
}
Message::CommandFinalize => {
// In the MText preview, Enter inserts a line break.
if self.mtext_editor.as_ref().is_some_and(|e| e.show_preview) {
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));
}
// A typed dynamic-input value commits as a point pick
// before the plain-Enter (on_enter) path runs.
if let Some(task) = self.try_dyn_commit() {
return task;
}
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_enter());
if let Some(r) = result {
return self.apply_cmd_result(r);
}
Task::none()
} else if let Some(cmd) = self.tabs[i].last_cmd.clone() {
self.dispatch_command(&cmd)
} else {
Task::none()
}
}
Message::CommandEscape => {
// Open MText editor swallows Escape (cancel without committing).
if self.mtext_editor.is_some() {
self.mtext_cancel();
return Task::none();
}
// The in-place TEXT editor likewise cancels on Escape.
if self.text_inline.is_some() {
self.text_inline_cancel();
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.grip_pending.take().is_some() {
self.command_line.input.clear();
return Task::none();
}
// A hot grip (click-move-click placement in progress) ends on
// Escape, leaving the entity at its last previewed position.
if self.tabs[self.active_tab].active_grip.take().is_some() {
// An Add-Leader arrow being placed: Esc removes it again.
if let Some((h, gid)) = self.grip_add_provisional.take() {
let i = self.active_tab;
use crate::entities::traits::EntityTypeOps;
if let Some(e) = self.tabs[i].scene.document.get_entity_mut(h) {
e.apply_grip_menu(
gid,
crate::scene::object::GripMenuAction::RemoveLeader,
);
}
self.tabs[i].scene.bump_geometry();
self.refresh_selected_grips();
}
// Cancel an in-progress grip drag: restore the edited
// entity from its pre-drag backup, un-hide it, re-tessellate
// once, and drop the preview.
if let Some(h) = self.grip_preview_handle.take() {
let i = self.active_tab;
if let Some(orig) = self.grip_original.take() {
if let Some(e) = self.tabs[i].scene.document.get_entity_mut(h) {
*e = orig;
}
}
self.tabs[i].scene.hidden.remove(&h);
self.tabs[i].scene.clear_preview_wire();
// Geometry restored to the backup — re-tessellate just it.
self.tabs[i].scene.mark_entity_dirty(h);
self.tabs[i].scene.bump_geometry_no_blocks();
self.refresh_selected_grips();
}
self.tabs[self.active_tab].snap_result = None;
self.refresh_properties();
return Task::none();
}
// Cancel layout rename / context menus first, then fall through.
let i_e = self.active_tab;
if self.qselect.take().is_some() {
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()
|| self.layout_context_menu.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;
sel.box_current = None;
sel.box_crossing = false;
}
Task::none()
}
Message::Command(cmd) => {
// Close viewport context menu if open.
let i = self.active_tab;
self.tabs[i].scene.selection.borrow_mut().context_menu = None;
// Any command also dismisses the Isolate action menu.
self.isolate_popup_open = false;
self.dispatch_command(&cmd)
}
Message::ToggleLayers => {
if let Some(id) = self.layer_window.take() {
// Close path: `OsWindowClosed`'s deactivate guard
// sees `layer_window == None` after the take(), so do
// it here so the button flips off in the same frame
// (#40).
self.ribbon.deactivate_tool_if("LAYERS");
window::close(id)
} else {
self.sync_ribbon_layers();
let (id, task) = window::open(window::Settings {
size: iced::Size::new(900.0, 360.0),
resizable: true,
level: window::Level::AlwaysOnTop,
..Default::default()
});
self.layer_window = Some(id);
task.map(|_| Message::Noop)
}
}
Message::WindowCloseRequested(id) => {
if self.main_window == Some(id) {
if self.tabs.iter().any(|t| t.dirty) {
self.pending_close = Some(super::PendingClose::Quit);
return self.open_unsaved_dialog_window();
}
return iced::exit();
}
Task::none()
}
Message::OsWindowClosed(id) => {
if self.main_window == Some(id) {
// Main window was explicitly closed by us — exit.
return iced::exit();
}
if self.unsaved_dialog_window == Some(id) {
// User closed the dialog window via OS ✕ — treat as Cancel.
self.unsaved_dialog_window = None;
self.pending_close = None;
return Task::none();
}
if self.save_dialog_window == Some(id) {
self.save_dialog_window = None;
return Task::none();
}
// Each popup window that's launched from a ribbon tool
// also turns that tool blue (`activate_tool`); when the
// window closes, the matching tool needs to be cleared
// or the button stays highlighted with no window behind
// it. The mapped IDs are the ribbon `ToolDef.id`s that
// dispatched the open in the first place. See #40.
if self.layer_window == Some(id) {
self.layer_window = None;
self.ribbon.deactivate_tool_if("LAYERS");
}
if self.page_setup_window == Some(id) {
self.page_setup_window = None;
self.ribbon.deactivate_tool_if("PAGESETUP");
}
if self.textstyle_window == Some(id) {
self.textstyle_window = None;
self.ribbon.deactivate_tool_if("STYLE");
self.ribbon.deactivate_tool_if("TEXTSTYLE");
}
if self.tablestyle_window == Some(id) {
self.tablestyle_window = None;
self.ribbon.deactivate_tool_if("TABLESTYLE");
}
if self.mlstyle_window == Some(id) {
self.mlstyle_window = None;
self.ribbon.deactivate_tool_if("MLSTYLE");
}
if self.mleaderstyle_window == Some(id) {
self.mleaderstyle_window = None;
self.ribbon.deactivate_tool_if("MLEADERSTYLE");
}
if self.layout_manager_window == Some(id) {
self.layout_manager_window = None;
self.ribbon.deactivate_tool_if("LAYOUTMANAGER");
self.ribbon.deactivate_tool_if("LAYOUTPANEL");
}
if self.plotstyle_window == Some(id) {
self.plotstyle_window = None;
self.ribbon.deactivate_tool_if("PLOTSTYLE");
self.ribbon.deactivate_tool_if("STYLESMANAGER");
}
if self.dimstyle_window == Some(id) {
self.dimstyle_window = None;
self.ribbon.deactivate_tool_if("DIMSTYLE");
}
if self.shortcuts_window == Some(id) {
self.shortcuts_window = None;
self.ribbon.deactivate_tool_if("SHORTCUTS");
self.ribbon.deactivate_tool_if("KEYBOARD");
}
if self.about_window == Some(id) {
self.about_window = None;
self.ribbon.deactivate_tool_if("ABOUT");
}
if self.update_notice_window == Some(id) {
self.update_notice_window = None;
}
Task::none()
}
// ── Layer panel messages ───────────────────────────────────────
Message::LayerToggleVisible(idx) => {
let i = self.active_tab;
if idx < self.tabs[i].layers.layers.len() {
self.push_undo_snapshot(i, "LAYER OFF/ON");
let l = &mut self.tabs[i].layers.layers[idx];
l.visible = !l.visible;
let name = l.name.clone();
let on = l.visible;
self.tabs[i].scene.toggle_layer_visibility(&name);
self.command_line.push_output(&format!(
"Layer \"{}\" {}",
name,
if on { "on" } else { "off" }
));
}
Task::none()
}
Message::LayerToggleLock(idx) => {
let i = self.active_tab;
if idx < self.tabs[i].layers.layers.len() {
self.push_undo_snapshot(i, "LAYER LOCK/UNLOCK");
let l = &mut self.tabs[i].layers.layers[idx];
l.locked = !l.locked;
let name = l.name.clone();
let locked = l.locked;
self.tabs[i].scene.toggle_layer_lock(&name);
self.command_line.push_output(&format!(
"Layer \"{}\" {}",
name,
if locked { "locked" } else { "unlocked" }
));
}
Task::none()
}
Message::LayerToggleFreeze(idx) => {
let i = self.active_tab;
if idx < self.tabs[i].layers.layers.len() {
self.push_undo_snapshot(i, "LAYER FREEZE");
let l = &mut self.tabs[i].layers.layers[idx];
l.frozen = !l.frozen;
let name = l.name.clone();
let frozen = l.frozen;
if let Some(dl) = self.tabs[i].scene.document.layers.get_mut(&name) {
if frozen {
dl.freeze();
} else {
dl.thaw();
}
}
self.tabs[i].scene.bump_geometry();
self.tabs[i].dirty = true;
}
Task::none()
}
Message::LayerToggleVpFreeze(layer_idx, vp_col_idx) => {
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 acadrust::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);
self.tabs[i].scene.bump_geometry();
self.tabs[i].dirty = true;
}
}
Task::none()
}
Message::LayerNew => {
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 acadrust::tables::layer::Layer as DocLayer;
let _ = self.tabs[i]
.scene
.document
.layers
.add(DocLayer::new(&new_name));
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.editing = Some(idx);
self.tabs[i].layers.edit_buf = new_name.clone();
}
self.sync_ribbon_layers();
Task::none()
}
Message::LayerDelete => {
let i = self.active_tab;
if let Some(idx) = self.tabs[i].layers.selected {
let name = self.tabs[i]
.layers
.layers
.get(idx)
.map(|l| l.name.clone())
.unwrap_or_default();
if name == "0" {
return Task::none();
}
self.push_undo_snapshot(i, "LAYER DELETE");
self.tabs[i].scene.document.layers.remove(&name);
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);
self.tabs[i].layers.selected = None;
self.sync_ribbon_layers();
}
Task::none()
}
Message::LayerSetCurrent => {
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();
self.tabs[i].active_layer = name.clone();
self.tabs[i].layers.current_layer = name.clone();
self.ribbon.active_layer = name;
}
}
Task::none()
}
Message::LayerSelect(idx) => {
let i = self.active_tab;
if self.tabs[i].layers.editing.is_some() {
return Task::done(Message::LayerRenameCommit);
}
self.tabs[i].layers.selected = Some(idx);
Task::none()
}
Message::LayerRenameStart(idx) => {
let i = self.active_tab;
self.tabs[i].layers.selected = Some(idx);
if let Some(layer) = self.tabs[i].layers.layers.get(idx) {
self.tabs[i].layers.edit_buf = layer.name.clone();
}
self.tabs[i].layers.editing = Some(idx);
Task::none()
}
Message::LayerRenameEdit(s) => {
let i = self.active_tab;
self.tabs[i].layers.edit_buf = s;
Task::none()
}
Message::LayerRenameCommit => {
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.tabs[i].scene.document.layers.contains(&new_name)
{
self.push_undo_snapshot(i, "LAYER RENAME");
if let Some(old_layer) = self.tabs[i].scene.document.layers.get(&old_name) {
use acadrust::tables::layer::Layer as DocLayer;
let mut nl = DocLayer::new(&new_name);
nl.color = old_layer.color.clone();
nl.flags = old_layer.flags.clone();
let _ = self.tabs[i].scene.document.layers.add(nl);
}
self.tabs[i].scene.document.layers.remove(&old_name);
for e in self.tabs[i].scene.document.entities_mut() {
if e.as_entity().layer() == old_name {
e.as_entity_mut().set_layer(new_name.clone());
}
}
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);
self.tabs[i].layers.edit_buf.clear();
self.sync_ribbon_layers();
}
Task::none()
}
Message::LayerColorPickerToggle(idx) => {
let i = self.active_tab;
let panel = &mut self.tabs[i].layers;
if panel.color_picker_row == Some(idx) {
panel.color_picker_row = None;
panel.color_full_palette = false;
} else {
panel.color_picker_row = Some(idx);
panel.color_full_palette = false;
panel.selected = Some(idx);
}
Task::none()
}
Message::LayerColorMorePalette => {
let i = self.active_tab;
self.tabs[i].layers.color_full_palette = !self.tabs[i].layers.color_full_palette;
Task::none()
}
Message::LayerColorSet(aci) => {
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 let Some(dl) = self.tabs[i].scene.document.layers.get_mut(&name) {
dl.color = AcadColor::Index(aci);
}
use crate::ui::layers::iced_color_from_acad;
let new_color = iced_color_from_acad(&AcadColor::Index(aci));
if let Some(pl) = self.tabs[i].layers.layers.get_mut(idx) {
pl.color = new_color;
}
self.tabs[i].dirty = true;
}
self.tabs[i].layers.color_picker_row = None;
self.tabs[i].layers.color_full_palette = false;
self.sync_ribbon_layers();
}
Task::none()
}
Message::LayerLinetypeSet(lt) => {
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 let Some(dl) = self.tabs[i].scene.document.layers.get_mut(&name) {
dl.line_type = lt.clone();
}
if let Some(pl) = self.tabs[i].layers.layers.get_mut(idx) {
pl.linetype = lt;
}
self.tabs[i].dirty = true;
}
}
Task::none()
}
Message::LayerLineweightSet(lw) => {
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 let Some(dl) = self.tabs[i].scene.document.layers.get_mut(&name) {
dl.line_weight = lw;
}
if let Some(pl) = self.tabs[i].layers.layers.get_mut(idx) {
pl.lineweight = lw;
}
self.tabs[i].dirty = true;
}
}
Task::none()
}
Message::LayerTransparencyEdit(idx, s) => {
let i = self.active_tab;
if let Some(pl) = self.tabs[i].layers.layers.get_mut(idx) {
if let Ok(v) = s.parse::<i32>() {
pl.transparency = v.clamp(0, 90);
} else if s.is_empty() {
pl.transparency = 0;
}
}
Task::none()
}
// ── Cursor / viewport messages ─────────────────────────────────
Message::CursorMoved(p) => {
// `p` is relative to the ViewCube hit area's top-left. Map
// it back to full-canvas coordinates so ViewportClick's
// hit-test lines up. The hit area sits in the top-right of
// the full canvas in model space, or of the active
// viewport's screen rectangle in a paper layout.
let i = self.active_tab;
let (vw, vh) = self.tabs[i].scene.selection.borrow().vp_size;
let (ox, oy) = match self
.tabs[i]
.scene
.active_viewport
.and_then(|h| self.tabs[i].scene.viewport_screen_rect(h, (vw, vh)))
{
Some(rect) => (
rect.x + rect.width - VIEWCUBE_PAD - VIEWCUBE_HIT_SIZE,
rect.y + VIEWCUBE_PAD,
),
None => {
// Model layout: the cube sits in the active tile's
// top-right corner.
let tb = self.tabs[i].scene.active_model_tile_bounds(vw, vh);
(
tb.x + tb.width - VIEWCUBE_PAD - VIEWCUBE_HIT_SIZE,
tb.y + VIEWCUBE_PAD,
)
}
};
self.cursor_pos = iced::Point::new(ox + p.x, oy + p.y);
// Drive the ViewCube hover highlight directly from this
// message — it fires whenever the cube's hit-area overlay
// sees motion, so we don't depend on the shader widget's
// `Program::update` receiving the same event (overlays sit
// above the shader and can mask it). Map the cursor into
// the active viewport's local box and use that box's size,
// since that's where the cube is actually drawn.
let tile = match self
.tabs[i]
.scene
.active_viewport
.and_then(|h| self.tabs[i].scene.viewport_screen_rect(h, (vw, vh)))
{
Some(rect) => rect,
None => self.tabs[i].scene.active_model_tile_bounds(vw, vh),
};
let cam_rot = self.tabs[i].scene.camera.borrow().view_rotation_mat();
let hover = hover_id(
self.cursor_pos.x - tile.x,
self.cursor_pos.y - tile.y,
tile.width,
tile.height,
cam_rot,
VIEWCUBE_PX,
);
self.tabs[i].scene.viewcube_hover.set(hover);
Task::none()
}
Message::ViewportMove(p) => {
let i = self.active_tab;
// A Model-tile divider drag short-circuits the rest of
// the move handling — the cursor neither pans the camera
// nor updates snap state while the user is resizing
// panes.
if let Some(drag) = self.tile_drag.as_mut() {
let (vw, vh) = self.tabs[i].scene.selection.borrow().vp_size;
if vw > 1.0 && vh > 1.0 {
let new_coord = match drag.orient {
TileEdgeOrient::Vertical => (p.x / vw).clamp(0.0, 1.0),
TileEdgeOrient::Horizontal => (p.y / vh).clamp(0.0, 1.0),
};
let (min_w, min_h) = tile_min_norm(vw, vh);
let min_size = match drag.orient {
TileEdgeOrient::Vertical => min_w,
TileEdgeOrient::Horizontal => min_h,
};
self.tabs[i].scene.move_model_tile_edge(
drag.orient,
drag.last_applied,
new_coord,
min_size,
);
drag.last_applied = new_coord;
self.tabs[i].scene.camera_generation += 1;
}
return Task::none();
}
// Keep the ViewCube hover in sync as the cursor leaves the
// hit-area overlay and moves over the rest of the viewport.
// `hover_id` returns None outside the cube box, which clears
// any stale highlight from the previous `CursorMoved`.
let (svw, svh) = self.tabs[i].scene.selection.borrow().vp_size;
let cube_tile = match self
.tabs[i]
.scene
.active_viewport
.and_then(|h| self.tabs[i].scene.viewport_screen_rect(h, (svw, svh)))
{
Some(rect) => rect,
None => self.tabs[i].scene.active_model_tile_bounds(svw, svh),
};
let cam_rot = self.tabs[i].scene.camera.borrow().view_rotation_mat();
self.tabs[i].scene.viewcube_hover.set(hover_id(
p.x - cube_tile.x,
p.y - cube_tile.y,
cube_tile.width,
cube_tile.height,
cam_rot,
VIEWCUBE_PX,
));
// Multi-functional grip hover: detect cursor sitting on a
// selected entity's grip and, after a dwell, open the
// popup menu. See scene::object::GripMenuItem.
self.update_grip_hover(i, p);
let mut sel = self.tabs[i].scene.selection.borrow_mut();
sel.last_move_pos = Some(p);
if sel.left_down {
let press = sel.left_press_pos.unwrap_or(p);
let dx = p.x - press.x;
let dy = p.y - press.y;
let dist2 = dx * dx + dy * dy;
let elapsed_ms = sel
.left_press_time
.map(|t| Instant::now().duration_since(t).as_millis())
.unwrap_or(u128::MAX);
if !sel.left_dragging && elapsed_ms >= POLY_START_DELAY_MS && dist2 > 9.0 {
sel.left_dragging = true;
sel.poly_active = true;
sel.poly_crossing = p.x < press.x;
sel.poly_points.clear();
sel.poly_points.push(press);
sel.poly_points.push(p);
} else if sel.left_dragging && sel.poly_active {
if sel.poly_points.last().map_or(true, |lp| {
let ddx = p.x - lp.x;
let ddy = p.y - lp.y;
ddx * ddx + ddy * ddy > 16.0
}) {
sel.poly_points.push(p);
}
}
} else if sel.box_anchor.is_some() {
sel.box_current = Some(p);
if let Some(a) = sel.box_anchor {
sel.box_crossing = p.x < a.x;
}
}
if sel.right_down {
if let Some(press) = sel.right_press_pos {
let dx = p.x - press.x;
let dy = p.y - press.y;
// 8 px (64 squared) threshold so normal hand jitter
// between a right-button press and release doesn't
// promote a click-and-release to an orbit drag, which
// would suppress the context menu on release.
if !sel.right_dragging && (dx * dx + dy * dy) > 64.0 {
sel.right_dragging = true;
sel.context_menu = None;
}
}
if sel.right_dragging {
if let Some(last) = sel.right_last_pos {
let (dx, dy) = (p.x - last.x, p.y - last.y);
if self.tabs[i].scene.active_viewport.is_some() {
// Update position before dropping the borrow.
sel.right_last_pos = Some(p);
drop(sel);
self.tabs[i].scene.orbit_active_viewport(dx, dy);
// Bump so the GPU re-uploads the viewport's
// re-culled wire set after the view rotates.
self.tabs[i].scene.camera_generation += 1;
return Task::none();
} else if self.tabs[i].scene.current_layout == "Model" {
sel.right_last_pos = Some(p);
drop(sel);
self.tabs[i].scene.camera.borrow_mut().orbit(dx, dy);
self.tabs[i].scene.camera_generation += 1;
return Task::none();
} else {
// Paper sheet is top-locked: right-drag never
// orbits it (orbiting would corrupt the camera
// frame and skew subsequent pans).
sel.right_last_pos = Some(p);
return Task::none();
}
} else {
sel.right_last_pos = Some(p);
}
}
}
let (mid_down, mid_last, vp_size) =
(sel.middle_down, sel.middle_last_pos, sel.vp_size);
if mid_down {
if let Some(last) = mid_last {
let (dx, dy) = (p.x - last.x, p.y - last.y);
// Pan scale uses the active tile's size (ortho size
// is relative to viewport height), so a tiled pane
// pans at the correct rate.
let bounds = self
.tabs[i]
.scene
.active_model_tile_bounds(vp_size.0, vp_size.1);
// Drop `sel` before calling mutable scene methods.
drop(sel);
if self.tabs[i].scene.active_viewport.is_some() {
self.tabs[i].scene.pan_active_viewport(dx, dy, bounds);
// Bump so the GPU re-uploads the viewport's re-culled
// wire set — otherwise newly-revealed lines stay
// invisible until MSPACE is exited.
self.tabs[i].scene.camera_generation += 1;
} else {
// `bounds` is the active tile; pan by its height so
// the point under the cursor tracks correctly.
self.tabs[i]
.scene
.camera
.borrow_mut()
.pan_screen(dx, dy, bounds.height);
self.tabs[i].scene.camera_generation += 1;
}
self.tabs[i].scene.selection.borrow_mut().middle_last_pos = Some(p);
return Task::none();
}
sel.middle_last_pos = Some(p);
}
let dragging = sel.left_down || sel.right_down || sel.middle_down;
let vp_size = sel.vp_size;
drop(sel);
// Hover (no button held): the tile under the cursor becomes
// active, so the camera + tile bounds used for picking below
// follow the pane the cursor is in. During a drag the active
// tile stays put so the operation finishes in its own pane.
if !dragging && vp_size.0 > 1.0 && vp_size.1 > 1.0 {
if self
.tabs[i]
.scene
.set_active_model_tile_at(p.x / vp_size.0, p.y / vp_size.1)
{
self.tabs[i].scene.camera_generation += 1;
self.sync_render_mode_to_active_tile(i);
}
}
// Tile-relative picking: shadow `p` with the cursor mapped
// into the active Model tile and `vp_size` with the tile's
// size, so every pick / snap / view_proj below operates in
// the active pane. `p_full` keeps the canvas-space cursor
// for screen overlays (cursor marker, snap glyph).
let p_full = p;
let tile_b = self
.tabs[i]
.scene
.active_model_tile_bounds(vp_size.0, vp_size.1);
let p = iced::Point {
x: p_full.x - tile_b.x,
y: p_full.y - tile_b.y,
};
let vp_size = (tile_b.width, tile_b.height);
// ── Grip drag ─────────────────────────────────────────────
if let Some(grip) = self.tabs[i].active_grip.clone() {
let (vw, vh) = vp_size;
let bounds = iced::Rectangle {
x: 0.0,
y: 0.0,
width: vw,
height: vh,
};
let cam = self.tabs[i].scene.camera.borrow();
let raw_paper = cam.pick_on_target_plane(p, bounds);
let vp_mat = cam.view_proj(bounds);
drop(cam);
let raw = self.tabs[i].scene.paper_to_model(raw_paper);
// First move of this drag: hide the edited entity from the
// base tessellation (one re-tess) so subsequent moves only
// refresh a cheap overlay preview instead of re-tessellating
// the whole model on every move.
if self.grip_preview_handle != Some(grip.handle) {
if let Some(prev) = self.grip_preview_handle.take() {
self.tabs[i].scene.hidden.remove(&prev);
}
// Back up the original geometry so Esc can cancel the drag.
self.grip_original =
self.tabs[i].scene.document.get_entity(grip.handle).cloned();
self.tabs[i].scene.hidden.insert(grip.handle);
// Grip drag never changes a block definition — keep the
// block cache so the hide doesn't re-tessellate blocks.
self.tabs[i].scene.bump_geometry_no_blocks();
self.grip_preview_handle = Some(grip.handle);
}
// The edited entity is hidden, so it's already absent from
// `hit_test_wires` — snap against the set directly, no clone
// and no self-snap.
let all_wires = self.tabs[i].scene.hit_test_wires();
let snap_hit = self.snapper.snap(raw, p, &all_wires[..], vp_mat, bounds);
let mut snapped = snap_hit.map(|s| s.world).unwrap_or(raw);
self.tabs[i].snap_result = snap_hit;
if let Some(s) = self.tabs[i].snap_result.as_mut() {
s.screen.x += tile_b.x;
s.screen.y += tile_b.y;
}
if snap_hit.is_none() {
let base = grip.origin_world;
if self.ortho_mode {
snapped = ortho_constrain(snapped, base);
} else if self.polar_mode {
snapped = polar_constrain(snapped, base, self.polar_increment_deg);
}
}
// Paper-space entities use sheet coordinates (no world_offset).
// Only add world_offset when converting local → DXF space in model space.
let wo_vec = if self.tabs[i].scene.current_layout == "Model" {
let wo = self.tabs[i].scene.world_offset;
glam::Vec3::new(wo[0] as f32, wo[1] as f32, wo[2] as f32)
} else {
glam::Vec3::ZERO
};
let apply = if grip.is_translate {
GripApply::Translate(snapped - grip.last_world)
} else {
GripApply::Absolute(snapped + wo_vec)
};
self.tabs[i]
.scene
.apply_grip(grip.handle, grip.grip_id, apply);
self.tabs[i].dirty = true;
self.tabs[i].active_grip.as_mut().unwrap().last_world = snapped;
// Overlay the moved entity (hidden from the base) — no base
// re-tessellation, just a one-entity preview tessellation.
let preview = self.tabs[i].scene.wire_models_for(&[grip.handle]);
self.tabs[i].scene.set_preview_wires(preview);
self.refresh_selected_grips();
self.refresh_properties();
return Task::none();
}
// Keep the coordinate readout live on every move, even with no
// active command. When a command is running the snap path below
// overwrites this with the snapped point.
{
let bounds = iced::Rectangle {
x: 0.0,
y: 0.0,
width: vp_size.0,
height: vp_size.1,
};
let paper = if let Some(ref ucs) = self.tabs[i].active_ucs {
let origin = glam::Vec3::new(
ucs.origin.x as f32,
ucs.origin.y as f32,
ucs.origin.z as f32,
);
let normal = ucs_z_axis(ucs);
self.tabs[i]
.scene
.camera
.borrow()
.pick_on_plane(p, bounds, normal, origin)
} else {
self.tabs[i]
.scene
.camera
.borrow()
.pick_on_target_plane(p, bounds)
};
let world = self.tabs[i].scene.paper_to_model(paper);
self.tabs[i].last_cursor_world = world;
}
// Rollover highlight: when idle (no active command, no drag),
// highlight the entity under the cursor. Decoupling selection
// from tessellation makes this cheap — it only refreshes the GPU
// xray overlay via `bump_selection`, never re-tessellates.
if !dragging && self.tabs[i].active_cmd.is_none() {
let bounds = iced::Rectangle {
x: 0.0,
y: 0.0,
width: vp_size.0,
height: vp_size.1,
};
let view_proj = self.tabs[i].scene.camera.borrow().view_proj(bounds);
let all_wires = self.tabs[i].scene.hit_test_wires();
let hovered = scene::hit_test::click_hit(p, &all_wires[..], view_proj, bounds)
.and_then(|s| Scene::handle_from_wire_name(s));
self.tabs[i].scene.set_hover_highlight(hovered);
} else {
// Suppress the rollover during a command or a drag.
self.tabs[i].scene.set_hover_highlight(None);
}
if self.tabs[i].active_cmd.is_some() {
let (vw, vh) = vp_size;
let bounds = iced::Rectangle {
x: 0.0,
y: 0.0,
width: vw,
height: vh,
};
let cursor_paper = if let Some(ref ucs) = self.tabs[i].active_ucs {
let origin = glam::Vec3::new(
ucs.origin.x as f32,
ucs.origin.y as f32,
ucs.origin.z as f32,
);
let normal = ucs_z_axis(ucs);
self.tabs[i]
.scene
.camera
.borrow()
.pick_on_plane(p, bounds, normal, origin)
} else {
self.tabs[i]
.scene
.camera
.borrow()
.pick_on_target_plane(p, bounds)
};
let view_proj = self.tabs[i].scene.camera.borrow().view_proj(bounds);
// Sync grid-snap spacing to the adaptive spacing of the visible grid.
self.snapper.grid_spacing =
crate::ui::overlay::compute_grid_step(view_proj, bounds);
// In MSPACE, map paper-space cursor to model space so that
// command previews and snapping work in the correct coordinate space.
let cursor_world = self.tabs[i].scene.paper_to_model(cursor_paper);
let all_wires = self.tabs[i].scene.hit_test_wires();
let needs_entity = self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.needs_entity_pick())
.unwrap_or(false);
let needs_structure = self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.needs_structure_point_pick())
.unwrap_or(false);
let is_gathering = self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.is_selection_gathering())
.unwrap_or(false);
let needs_tan = self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.needs_tangent_pick())
.unwrap_or(false);
self.tabs[i].snap_result = if needs_entity || is_gathering || needs_structure {
None
} else if needs_tan {
self.snapper.snap_tangent_only(
cursor_world,
p,
&all_wires[..],
view_proj,
bounds,
)
} else {
self.snapper
.snap(cursor_world, p, &all_wires[..], view_proj, bounds)
};
// Object Snap Tracking: update dwell and override snap if tracking.
let otrack_snap_world = {
let snap_world = self.tabs[i].snap_result.map(|s| s.world);
self.snapper
.update_otrack_dwell(snap_world, view_proj, bounds);
if self.tabs[i].snap_result.is_none() {
self.snapper
.otrack_snap(cursor_world, view_proj, bounds)
.map(|(w, _)| w)
} else {
None
}
};
if let Some(ow) = otrack_snap_world {
// Override the effective point with the OST alignment.
// (don't set snap_result so the normal snap marker stays hidden)
self.tabs[i].last_cursor_world = ow;
}
let effective = {
// snap.world is paper-space for viewport-projected wires; convert
// to model-space so previews use consistent coordinates.
let mut pt = self.tabs[i]
.snap_result
.map(|s| self.tabs[i].scene.paper_to_model(s.world))
.unwrap_or(cursor_world);
// Clamp to world XY only when no UCS is active; with a UCS the
// point already lies on the UCS XY plane.
if self.tabs[i].active_cmd.is_some() && self.tabs[i].active_ucs.is_none() {
pt.z = 0.0;
}
if let Some(base) = self.last_point {
if self.ortho_mode {
pt = ortho_constrain(pt, base);
} else if self.polar_mode {
pt = polar_constrain(pt, base, self.polar_increment_deg);
}
}
pt
};
self.tabs[i].last_cursor_world = effective;
self.tabs[i].last_cursor_screen = p_full;
// C3D-style orange object snap (plugin commands implement resolve_object_pick).
if needs_structure {
use crate::snap::{SnapResult, SnapType};
let pick = self.tabs[i].active_cmd.as_ref().and_then(|c| {
c.resolve_object_pick(
&self.tabs[i].scene,
effective.x as f64,
effective.y as f64,
)
});
if let Some(pick) = pick {
let world =
glam::Vec3::new(pick.x as f32, pick.y as f32, effective.z);
let ndc = view_proj.project_point3(world);
let screen = iced::Point::new(
(ndc.x + 1.0) * 0.5 * bounds.width,
(1.0 - ndc.y) * 0.5 * bounds.height,
);
self.tabs[i].snap_result = Some(SnapResult {
world,
screen,
snap_type: SnapType::ObjectPick,
tangent_obj: None,
});
if let Some(cmd) = self.tabs[i].active_cmd.as_mut() {
cmd.set_acquisition_hint(Some(pick.label));
}
} else if let Some(cmd) = self.tabs[i].active_cmd.as_mut() {
cmd.set_acquisition_hint(None);
}
}
// Snap glyph is positioned in canvas space; shift the
// tile-local snap screen point back to the full canvas.
if let Some(s) = self.tabs[i].snap_result.as_mut() {
s.screen.x += tile_b.x;
s.screen.y += tile_b.y;
}
let mut previews = if needs_structure {
let mut p = self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.object_pick_hover_previews(&self.tabs[i].scene, effective))
.unwrap_or_default();
if let Some(cmd) = self.tabs[i].active_cmd.as_mut() {
p.extend(cmd.on_preview_wires(effective));
}
p
} else if needs_entity {
let hover_handle =
scene::hit_test::click_hit(p, &all_wires[..], view_proj, bounds)
.and_then(|s| Scene::handle_from_wire_name(s))
.unwrap_or(acadrust::Handle::NULL);
let mut p = self.tabs[i]
.active_cmd
.as_mut()
.map(|c| c.on_hover_entity(hover_handle, effective))
.unwrap_or_default();
if !hover_handle.is_null() {
if let Some(cmd) = self.tabs[i].active_cmd.as_ref() {
p.extend(cmd.entity_pick_acquire_previews(
&self.tabs[i].scene,
hover_handle,
));
}
if let Some(cmd) = self.tabs[i].active_cmd.as_mut() {
if let Some(hint) =
cmd.entity_pick_acquire_hint(hover_handle)
{
cmd.set_acquisition_hint(Some(hint));
}
}
}
p
} else {
self.tabs[i]
.active_cmd
.as_mut()
.map(|c| c.on_preview_wires(effective))
.unwrap_or_default()
};
// Polar tracking guide line: dotted line from last_point along
// the snapped angle direction, extending across the drawing.
if self.polar_mode && !needs_entity {
if let Some(base) = self.last_point {
let dx = effective.x - base.x;
let dy = effective.y - base.y;
if (dx * dx + dy * dy).sqrt() > 1e-4 {
let far = 1e5_f32;
let dir = glam::Vec3::new(dx, dy, 0.0).normalize();
let far_pos = base + dir * far;
let far_neg = base - dir * far;
let guide = crate::scene::WireModel {
name: "__polar_guide__".into(),
points: vec![
[far_neg.x, far_neg.y, far_neg.z],
[far_pos.x, far_pos.y, far_pos.z],
],
color: [0.2, 0.7, 0.9, 0.6],
selected: false,
aci: 0,
pattern_length: 0.8,
pattern: [0.5, -0.3, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
line_weight_px: 1.0,
snap_pts: vec![],
tangent_geoms: vec![],
key_vertices: vec![],
aabb: crate::scene::WireModel::UNBOUNDED_AABB,
plinegen: true,
vp_scissor: None,
fill_tris: vec![],
};
previews.push(guide);
}
}
}
self.tabs[i].scene.set_preview_wires(previews);
} else {
self.tabs[i].snap_result = None;
}
self.sync_dyn_fields();
Task::none()
}
Message::ViewportExit => {
let i = self.active_tab;
let mut sel = self.tabs[i].scene.selection.borrow_mut();
sel.left_down = false;
sel.left_press_pos = None;
sel.left_press_time = None;
sel.left_dragging = false;
sel.right_down = false;
sel.right_press_pos = None;
sel.right_last_pos = None;
sel.right_dragging = false;
sel.middle_down = false;
sel.middle_last_pos = None;
sel.box_anchor = None;
sel.box_current = None;
sel.box_crossing = false;
sel.poly_active = false;
sel.poly_points.clear();
sel.poly_crossing = false;
drop(sel);
// Clear the rollover highlight when the cursor leaves the
// viewport so it doesn't stick while the mouse is over the
// ribbon / panels.
self.tabs[i].scene.set_hover_highlight(None);
// Don't touch `context_menu` here. ViewportExit also fires
// when an upper overlay (the right-click menu panel) takes
// the cursor, so clearing the menu state on every exit
// would close the menu the moment it opens. Outside-click
// dismiss is handled in `ViewportLeftPress`.
Task::none()
}
Message::ViewportLeftPress => {
let i = self.active_tab;
// A click in the viewport dismisses any open ribbon dropdown
// (e.g. the annotation style combo), which has no backdrop of
// its own to catch outside clicks.
self.ribbon.close_dropdown();
// Click anywhere outside the popup dismisses it. The
// menu's own buttons live above this mouse_area, so a
// press that reaches here means the cursor is not on
// any menu item.
if self.grip_popup.take().is_some() {
self.grip_hover = None;
return Task::none();
}
// Same dismiss-on-outside-click for the right-click
// context menu: its panel is opaque, so a press that
// reaches here is outside the menu.
{
let mut sel = self.tabs[i].scene.selection.borrow_mut();
if sel.context_menu.take().is_some() {
return Task::none();
}
}
let (p, vp_size) = {
let sel = self.tabs[i].scene.selection.borrow();
let p = match sel.last_move_pos {
Some(p) => p,
None => return Task::none(),
};
(p, sel.vp_size)
};
let (vw, vh) = vp_size;
if vw > 1.0 && vh > 1.0 {
let cam = self.tabs[i].scene.camera.borrow();
if scene::hit_test(p.x, p.y, vw, vh, cam.view_rotation_mat(), VIEWCUBE_PX)
.is_some()
{
return Task::none();
}
}
// Tiled Model layout: an inner divider near the cursor
// becomes a resize grip. Start the drag and short-circuit
// (no pick / select / camera swap). Released on
// `ViewportLeftRelease`.
if self.tabs[i].active_cmd.is_none()
&& self.tabs[i].scene.current_layout == "Model"
&& vw > 1.0
&& vh > 1.0
{
let canvas_bounds = iced::Rectangle {
x: 0.0,
y: 0.0,
width: vw,
height: vh,
};
if let Some(edge) = self.tabs[i]
.scene
.hit_model_tile_edge(p, canvas_bounds, TILE_EDGE_HIT_PX)
{
self.tile_drag = Some(crate::app::TileDrag {
orient: edge.orient,
last_applied: edge.coord,
});
return Task::none();
}
}
// Tiled Model layout: clicking a non-active tile activates
// it (swapping in its camera) instead of selecting / drawing.
if self.tabs[i].active_cmd.is_none() && vw > 1.0 && vh > 1.0 {
if self
.tabs[i]
.scene
.set_active_model_tile_at(p.x / vw, p.y / vh)
{
self.tabs[i].scene.camera_generation += 1;
self.sync_render_mode_to_active_tile(i);
return Task::none();
}
}
// From here the click targets the active tile: map the
// cursor into it and use the tile's size for picking, so
// grip / selection hit-tests land in the right pane.
let p_full = p;
let tile_b = self.tabs[i].scene.active_model_tile_bounds(vw, vh);
let p = iced::Point {
x: p_full.x - tile_b.x,
y: p_full.y - tile_b.y,
};
let (vw, vh) = (tile_b.width, tile_b.height);
let bounds = iced::Rectangle {
x: 0.0,
y: 0.0,
width: vw,
height: vh,
};
if self.tabs[i].active_cmd.is_none()
&& self.tabs[i].active_grip.is_none()
&& !self.tabs[i].selected_grips.is_empty()
{
if let Some(handle) = self.tabs[i].selected_handle {
let is_paper = self.tabs[i].scene.current_layout != "Model";
let grip_hit = if is_paper {
let cam = self.tabs[i].scene.camera.borrow();
let aspect = if vh > 0.0 { vw / vh } else { 1.0 };
let half_h = cam.ortho_size();
let half_w = half_h * aspect;
let tx = cam.target.x;
let ty = cam.target.y;
drop(cam);
find_hit_grip_paper(
p,
&self.tabs[i].selected_grips,
tx,
ty,
half_w,
half_h,
bounds,
)
} else {
let vp_mat = self.tabs[i].scene.camera.borrow().view_proj(bounds);
find_hit_grip(p, &self.tabs[i].selected_grips, vp_mat, bounds)
};
if let Some((grip_id, is_translate, world)) = grip_hit {
self.tabs[i].active_grip = Some(GripEdit {
handle,
grip_id,
is_translate,
origin_world: world,
last_world: world,
});
self.grip_hover = None;
self.grip_popup = None;
return Task::none();
}
}
}
let mut sel = self.tabs[i].scene.selection.borrow_mut();
sel.left_down = true;
// Stored in full-canvas space (like ViewportMove's cursor and
// the overlay box / lasso drawing); release maps it into the
// active tile. Tile-local here would double-offset the anchor.
sel.left_press_pos = Some(p_full);
sel.left_press_time = Some(Instant::now());
sel.left_dragging = false;
Task::none()
}
Message::ViewportLeftRelease => {
let i = self.active_tab;
// End an in-flight tile-divider drag. Any tile that fell
// below the minimum (viewcube fits comfortably) gets
// absorbed into its longest-contact neighbour, so the
// user can drag a divider all the way to one side to
// remove a pane.
if self.tile_drag.take().is_some() {
let (vw, vh) = self.tabs[i].scene.selection.borrow().vp_size;
let (min_w, min_h) = tile_min_norm(vw, vh);
self.tabs[i]
.scene
.collapse_small_model_tiles(min_w, min_h);
self.tabs[i].scene.camera_generation += 1;
return Task::none();
}
let (p, is_click, is_down) = {
let sel = self.tabs[i].scene.selection.borrow();
let p = match sel.last_move_pos {
Some(p) => p,
None => return Task::none(),
};
(p, !sel.left_dragging, sel.left_down)
};
// Grip editing: click-move-click (plus legacy press-drag).
// The grip engages on press (active_grip set). This release
// commits only if the grip has actually moved, or if it was a
// press-drag. A bare engaging click (no movement yet) keeps the
// grip hot so the user can move the cursor and click again to
// place it. Escape cancels (handled elsewhere).
if let Some(grip) = self.tabs[i].active_grip.clone() {
// Reset mouse state so the lingering press from the engaging
// click doesn't read as an in-progress drag on later moves.
{
let mut sel = self.tabs[i].scene.selection.borrow_mut();
sel.left_down = false;
sel.left_press_pos = None;
sel.left_press_time = None;
sel.left_dragging = false;
}
let moved = grip.last_world != grip.origin_world;
if is_click && !moved {
// Engaging click — stay hot, wait for the placement click.
return Task::none();
}
self.tabs[i].active_grip = None;
// Commit the grip drag: keep the doc's dragged geometry
// (drop the cancel backup), un-hide the edited entity and
// re-tessellate the base once, dropping the overlay preview.
if let Some(h) = self.grip_preview_handle.take() {
self.grip_original = None;
self.tabs[i].scene.hidden.remove(&h);
self.tabs[i].scene.clear_preview_wire();
// Only the dragged entity changed — re-tessellate just it.
self.tabs[i].scene.mark_entity_dirty(h);
self.tabs[i].scene.bump_geometry_no_blocks();
}
// Placement confirmed — keep the just-added leader.
self.grip_add_provisional = None;
self.tabs[i].snap_result = None;
self.refresh_properties();
return Task::none();
}
// Map the release point into the active Model tile so the
// click's pick / on_point / selection use the active pane's
// camera + bounds. `p_full` keeps the canvas point for the
// box/poly selection rectangle (drawn in canvas space).
let p_full = p;
let (tile_vw, tile_vh, tile_off) = {
let (svw, svh) = self.tabs[i].scene.selection.borrow().vp_size;
let tb = self.tabs[i].scene.active_model_tile_bounds(svw, svh);
(tb.width, tb.height, iced::Point::new(tb.x, tb.y))
};
let p = iced::Point {
x: p_full.x - tile_off.x,
y: p_full.y - tile_off.y,
};
let is_gathering = self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.is_selection_gathering())
.unwrap_or(false);
if is_down && is_click && self.tabs[i].active_cmd.is_some() && !is_gathering {
let (vw, vh) = (tile_vw, tile_vh);
let bounds = iced::Rectangle {
x: 0.0,
y: 0.0,
width: vw,
height: vh,
};
let snap_taken = self.tabs[i].snap_result.take();
let tangent_obj_at_click = snap_taken.and_then(|s| s.tangent_obj);
let world_pt = {
// Project screen point onto the active UCS XY plane (or world XY when
// no UCS is active).
let raw_paper = if let Some(ref ucs) = self.tabs[i].active_ucs {
let origin = glam::Vec3::new(
ucs.origin.x as f32,
ucs.origin.y as f32,
ucs.origin.z as f32,
);
let normal = ucs_z_axis(ucs);
self.tabs[i]
.scene
.camera
.borrow()
.pick_on_plane(p, bounds, normal, origin)
} else {
self.tabs[i]
.scene
.camera
.borrow()
.pick_on_target_plane(p, bounds)
};
// Convert paper-space → model-space when inside a viewport.
let raw = self.tabs[i].scene.paper_to_model(raw_paper);
let vp_mat = self.tabs[i].scene.camera.borrow().view_proj(bounds);
let all_wires = self.tabs[i].scene.hit_test_wires();
let needs_tan = self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.needs_tangent_pick())
.unwrap_or(false);
let needs_entity_click = self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.needs_entity_pick())
.unwrap_or(false);
let snap_hit = if needs_entity_click {
None
} else if needs_tan {
self.snapper
.snap_tangent_only(raw, p, &all_wires[..], vp_mat, bounds)
} else {
self.snapper.snap(raw, p, &all_wires[..], vp_mat, bounds)
};
// snap.world is in paper-space (projected wire coords in MSPACE);
// convert to model-space so commands receive consistent coordinates.
let mut pt = snap_hit
.map(|s| self.tabs[i].scene.paper_to_model(s.world))
.unwrap_or(raw);
// When no UCS is active clamp to world XY; with a UCS the point is
// already constrained to that plane by the rayplane intersection.
if self.tabs[i].active_ucs.is_none() {
pt.z = 0.0;
}
if let Some(base) = self.last_point {
if self.ortho_mode {
pt = ortho_constrain(pt, base);
} else if self.polar_mode {
pt = polar_constrain(pt, base, self.polar_increment_deg);
}
}
pt
};
let result = if self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.needs_structure_point_pick())
.unwrap_or(false)
{
let pick = self.tabs[i].active_cmd.as_ref().and_then(|c| {
c.resolve_object_pick(
&self.tabs[i].scene,
world_pt.x as f64,
world_pt.y as f64,
)
});
if let Some(pick) = pick {
let center = glam::Vec3::new(pick.x as f32, pick.y as f32, world_pt.z);
let result = self.tabs[i].active_cmd.as_mut().map(|c| {
c.on_structure_pick(pick.handle, center)
});
self.command_line
.push_info(&format!("{} acquired.", pick.label));
result
} else {
let msg = self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.object_pick_miss_message())
.unwrap_or("No object near click.");
self.command_line.push_error(msg);
None
}
} else if self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.needs_entity_pick())
.unwrap_or(false)
{
let vp_mat2 = self.tabs[i].scene.camera.borrow().view_proj(bounds);
let all_wires2 = self.tabs[i].scene.hit_test_wires();
let hit = scene::hit_test::click_hit(p, &all_wires2[..], vp_mat2, bounds)
.and_then(|s| Scene::handle_from_wire_name(s));
if let Some(handle) = hit {
// Some commands (e.g. SS_CATCHMENT) need the entity
// body before `on_entity_pick` can advance.
let inject_first = self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.inject_before_entity_pick())
.unwrap_or(false);
if inject_first {
if let Some(entity) =
self.tabs[i].scene.document.get_entity(handle).cloned()
{
if let Some(cmd) = self.tabs[i].active_cmd.as_mut() {
cmd.inject_picked_entity(entity);
}
}
}
let result = self.tabs[i]
.active_cmd
.as_mut()
.map(|c| c.on_entity_pick(handle, world_pt));
// HATCHEDIT: after pick, inject hatch model data into the command.
if self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.name() == "HATCHEDIT")
.unwrap_or(false)
{
if let Some(model) =
self.tabs[i].scene.hatches.get(&handle).cloned()
{
use crate::command::CadCommand;
use crate::modules::home::draw::hatchedit::HatcheditCommand;
let cmd: Box<dyn CadCommand> =
Box::new(HatcheditCommand::with_handle(
handle,
model.name.clone(),
model.scale,
model.angle_offset,
));
self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(cmd);
} else {
self.command_line
.push_error("HATCHEDIT: not a hatch entity.");
self.tabs[i].active_cmd = None;
}
}
// DIMTEDIT / MLEADERADD / MLEADERREMOVE: inject cloned entity via trait.
{
let needs_inject = self.tabs[i]
.active_cmd
.as_ref()
.map(|c| {
matches!(
c.name(),
"DIMTEDIT" | "MLEADERADD" | "MLEADERREMOVE"
)
})
.unwrap_or(false);
if needs_inject {
if let Some(entity) =
self.tabs[i].scene.document.get_entity(handle).cloned()
{
if let Some(cmd) = self.tabs[i].active_cmd.as_mut() {
cmd.inject_picked_entity(entity);
let prompt = cmd.prompt();
self.command_line.push_info(&prompt);
}
}
}
}
result
} else {
self.command_line.push_info("Nothing found at that point.");
None
}
} else if self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.needs_tangent_pick())
.unwrap_or(false)
{
if let Some(obj) = tangent_obj_at_click {
self.tabs[i]
.active_cmd
.as_mut()
.map(|c| c.on_tangent_point(obj, world_pt))
} else {
self.command_line.push_info("Select a tangent object.");
None
}
} else {
self.last_point = Some(world_pt);
self.dyn_user_reshaped = false;
self.sync_dyn_fields();
self.tabs[i]
.active_cmd
.as_mut()
.map(|c| c.on_point(world_pt))
};
if let Some(r) = result {
let task = self.apply_cmd_result(r);
let mut sel = self.tabs[i].scene.selection.borrow_mut();
sel.left_down = false;
sel.left_press_pos = None;
sel.left_press_time = None;
sel.left_dragging = false;
return task;
}
let mut sel = self.tabs[i].scene.selection.borrow_mut();
sel.left_down = false;
sel.left_press_pos = None;
sel.left_press_time = None;
sel.left_dragging = false;
return Task::none();
}
let (is_down2, is_dragging, box_anchor, box_crossing, _vp_size, elapsed_ms) = {
let sel = self.tabs[i].scene.selection.borrow();
let elapsed = sel
.left_press_time
.map(|t| Instant::now().duration_since(t).as_millis())
.unwrap_or(u128::MAX);
(
sel.left_down,
sel.left_dragging,
sel.box_anchor,
sel.box_crossing,
sel.vp_size,
elapsed,
)
};
let mut selection_just_completed = false;
// Active-tile-local selection: tile-sized bounds and the box
// anchor mapped into the tile, so box / crossing selection
// matches the active pane (p is already tile-local).
let vp_size = (tile_vw, tile_vh);
let box_anchor = box_anchor.map(|a| iced::Point {
x: a.x - tile_off.x,
y: a.y - tile_off.y,
});
if is_down2 {
let bounds = iced::Rectangle {
x: 0.0,
y: 0.0,
width: vp_size.0,
height: vp_size.1,
};
if is_dragging {
if elapsed_ms < POLY_START_DELAY_MS {
if let Some(a) = box_anchor {
let crossing = box_crossing;
let all_wires = self.tabs[i].scene.hit_test_wires();
let vp_mat = self.tabs[i].scene.camera.borrow().view_proj(bounds);
let mut handles: Vec<Handle> = scene::hit_test::box_hit(
a,
p,
crossing,
&all_wires[..],
vp_mat,
bounds,
)
.into_iter()
.filter_map(|s| Scene::handle_from_wire_name(s))
.collect();
handles.extend(scene::hit_test::box_hit_hatch(
a,
p,
crossing,
&self.tabs[i].scene.visible_hatches_for_click(),
vp_mat,
bounds,
));
self.tabs[i].scene.deselect_all();
for h in &handles {
self.tabs[i].scene.select_entity(*h, false);
}
self.tabs[i].scene.expand_selection_for_groups(&handles);
self.refresh_properties();
selection_just_completed = true;
}
} else {
let (poly_pts, crossing) = {
let sel = self.tabs[i].scene.selection.borrow();
// Map lasso points into the active tile.
let pts: Vec<iced::Point> = sel
.poly_points
.iter()
.map(|pp| iced::Point {
x: pp.x - tile_off.x,
y: pp.y - tile_off.y,
})
.collect();
(pts, sel.poly_crossing)
};
self.tabs[i].scene.selection.borrow_mut().poly_last_crossing = crossing;
let all_wires = self.tabs[i].scene.hit_test_wires();
let vp_mat = self.tabs[i].scene.camera.borrow().view_proj(bounds);
let mut handles: Vec<Handle> = scene::hit_test::poly_hit(
&poly_pts,
crossing,
&all_wires[..],
vp_mat,
bounds,
)
.into_iter()
.filter_map(|s| Scene::handle_from_wire_name(s))
.collect();
handles.extend(scene::hit_test::poly_hit_hatch(
&poly_pts,
crossing,
&self.tabs[i].scene.visible_hatches_for_click(),
vp_mat,
bounds,
));
// Selection filter: keep only allowed types.
handles.retain(|&h| self.tabs[i].scene.passes_selection_filter(h));
self.tabs[i].scene.deselect_all();
for h in &handles {
self.tabs[i].scene.select_entity(*h, false);
}
self.tabs[i].scene.expand_selection_for_groups(&handles);
self.refresh_properties();
selection_just_completed = true;
}
let mut sel = self.tabs[i].scene.selection.borrow_mut();
sel.poly_active = false;
sel.poly_points.clear();
sel.poly_crossing = false;
sel.box_anchor = None;
sel.box_current = None;
} else {
if box_anchor.is_none() {
let all_wires = self.tabs[i].scene.hit_test_wires();
let vp_mat = self.tabs[i].scene.camera.borrow().view_proj(bounds);
// Selection cycling: where two or more objects
// overlap, open a list box to pick which one; a
// single object falls through to the normal click.
// Gated behind the toggle, so default picking is
// unchanged when off.
let mut handled_by_cycling = false;
if self.selection_cycling {
let cands: Vec<Handle> = scene::hit_test::click_hits_all(
p,
&all_wires[..],
vp_mat,
bounds,
)
.into_iter()
.filter_map(|s| Scene::handle_from_wire_name(s))
.filter(|&h| self.tabs[i].scene.passes_selection_filter(h))
.collect();
if cands.len() >= 2 {
// Overlap: open the list box at the cursor.
self.cycle_candidates = Some((p_full, cands));
handled_by_cycling = true;
}
}
if !handled_by_cycling {
let hit = scene::hit_test::click_hit(p, &all_wires[..], vp_mat, bounds)
.and_then(|s| Scene::handle_from_wire_name(s))
.or_else(|| {
scene::hit_test::click_hit_hatch(
p,
&self.tabs[i].scene.visible_hatches_for_click(),
vp_mat,
bounds,
)
})
.or_else(|| {
// Block-internal hatch: resolve to the
// parent Insert (AutoCAD behaviour).
scene::hit_test::click_hit_insert_hatch(
p,
&self.tabs[i].scene.insert_hatches_for_click(),
vp_mat,
bounds,
)
})
.or_else(|| {
// 3D solids: click anywhere on the shaded
// body, not just the thin projected edges.
self.tabs[i].scene.mesh_click_hit(p, vp_mat, bounds)
});
// Selection filter: drop a pick whose type is excluded.
let hit =
hit.filter(|&h| self.tabs[i].scene.passes_selection_filter(h));
if let Some(handle) = hit {
// Individual picks accumulate (issue #47):
// each plain click adds to the selection,
// Shift+click removes the picked entity.
// Esc / empty-space click clears.
if self.shift_down {
self.tabs[i].scene.deselect_entity(handle);
} else {
self.tabs[i].scene.select_entity(handle, false);
self.tabs[i].scene.expand_selection_for_groups(&[handle]);
}
self.refresh_properties();
selection_just_completed = true;
} else {
self.tabs[i].scene.deselect_all();
self.refresh_properties();
let mut sel = self.tabs[i].scene.selection.borrow_mut();
// Full-canvas space: ViewportMove updates
// box_current in canvas coords and the overlay
// draws there; release maps back into the tile.
sel.box_anchor = Some(p_full);
sel.box_current = Some(p_full);
sel.box_crossing = false;
}
}
} else {
let a = box_anchor.unwrap();
let crossing = box_crossing;
let all_wires = self.tabs[i].scene.hit_test_wires();
let vp_mat = self.tabs[i].scene.camera.borrow().view_proj(bounds);
let mut handles: Vec<Handle> = scene::hit_test::box_hit(
a,
p,
crossing,
&all_wires[..],
vp_mat,
bounds,
)
.into_iter()
.filter_map(|s| Scene::handle_from_wire_name(s))
.collect();
handles.extend(scene::hit_test::box_hit_hatch(
a,
p,
crossing,
&self.tabs[i].scene.visible_hatches_for_click(),
vp_mat,
bounds,
));
// Selection filter: keep only allowed types.
handles.retain(|&h| self.tabs[i].scene.passes_selection_filter(h));
self.tabs[i].scene.deselect_all();
for h in &handles {
self.tabs[i].scene.select_entity(*h, false);
}
self.tabs[i].scene.expand_selection_for_groups(&handles);
self.refresh_properties();
let mut sel = self.tabs[i].scene.selection.borrow_mut();
sel.box_last = Some((a, p));
sel.box_last_crossing = crossing;
sel.box_anchor = None;
sel.box_current = None;
sel.box_crossing = false;
selection_just_completed = true;
}
}
let mut sel = self.tabs[i].scene.selection.borrow_mut();
sel.left_down = false;
sel.left_press_pos = None;
sel.left_press_time = None;
sel.left_dragging = false;
}
if is_gathering && selection_just_completed {
let handles: Vec<Handle> = self.tabs[i]
.scene
.selected_entities()
.into_iter()
.map(|(h, _)| h)
.collect();
if let Some(cmd) = self.tabs[i].active_cmd.as_mut() {
let result = cmd.on_selection_complete(handles);
return self.apply_cmd_result(result);
}
}
// ── Double-click in Model Space: DDEDIT for Text/MText ────
if is_click
&& is_down
&& self.tabs[i].active_cmd.is_none()
&& self.tabs[i].scene.current_layout == "Model"
{
let now = Instant::now();
let is_double_model = self
.last_vp_click_time
.map(|t| {
let dt = now.duration_since(t).as_millis();
let last = self.last_vp_click_pos.unwrap_or(p);
let d = (p.x - last.x).hypot(p.y - last.y);
dt < 400 && d < 8.0
})
.unwrap_or(false);
self.last_vp_click_time = Some(now);
self.last_vp_click_pos = Some(p);
if is_double_model {
let (vw, vh) = self.tabs[i].scene.selection.borrow().vp_size;
let bounds = iced::Rectangle {
x: 0.0,
y: 0.0,
width: vw,
height: vh,
};
let vp_mat = self.tabs[i].scene.camera.borrow().view_proj(bounds);
let all_wires = self.tabs[i].scene.hit_test_wires();
let hit = scene::hit_test::click_hit(p, &all_wires[..], vp_mat, bounds)
.and_then(|s| Scene::handle_from_wire_name(s));
if let Some(handle) = hit {
// Any text-bearing entity opens its in-place editor
// (plain box or rich MText editor, per type). A
// Leader resolves to the entity it annotates.
let is_editable_text = self
.tabs[i]
.scene
.document
.get_entity(handle)
.is_some_and(|e| {
super::text_inline::read_text_field(e).is_some()
|| matches!(e, AcadEntityType::Leader(_))
});
if is_editable_text {
return self.begin_text_edit(handle);
}
}
}
}
// ── Double-click: enter/exit MSPACE ───────────────────────
// Only when no command is running, no drag, and we're in paper space.
if is_click
&& is_down // ensures there was a matching left-press
&& self.tabs[i].active_cmd.is_none()
&& self.tabs[i].scene.current_layout != "Model"
{
let now = Instant::now();
let is_double = self
.last_vp_click_time
.map(|t| {
let dt = now.duration_since(t).as_millis();
let last = self.last_vp_click_pos.unwrap_or(p);
let d = (p.x - last.x).hypot(p.y - last.y);
dt < 400 && d < 8.0
})
.unwrap_or(false);
self.last_vp_click_time = Some(now);
self.last_vp_click_pos = Some(p);
if is_double {
let (vw, vh) = self.tabs[i].scene.selection.borrow().vp_size;
let bounds = iced::Rectangle {
x: 0.0,
y: 0.0,
width: vw,
height: vh,
};
// 1) Try direct wire hit — works when the border is clicked.
let hit_vp: Option<acadrust::Handle> = {
let vp_mat = self.tabs[i].scene.camera.borrow().view_proj(bounds);
let all_wires = self.tabs[i].scene.hit_test_wires();
scene::hit_test::click_hit(p, &all_wires[..], vp_mat, bounds)
.and_then(|s| Scene::handle_from_wire_name(s))
.and_then(|h| {
if let Some(AcadEntityType::Viewport(vp)) =
self.tabs[i].scene.document.get_entity(h)
{
if Scene::is_content_viewport(vp) {
Some(h)
} else {
None
}
} else {
None
}
})
};
// 2) Geometric fallback: check if the cursor is inside any
// viewport's bounding rectangle in paper space. This handles
// double-clicks on model-entity content wires or empty areas.
let hit_vp = hit_vp.or_else(|| {
let paper_pt = self.tabs[i]
.scene
.camera
.borrow()
.pick_on_target_plane(p, bounds);
self.tabs[i]
.scene
.viewport_at_paper_point(paper_pt.x, paper_pt.y)
});
if let Some(handle) = hit_vp {
return Task::done(Message::EnterViewport(handle));
} else if self.tabs[i].scene.active_viewport.is_some() {
// Double-clicked outside all viewports while in MSPACE → exit.
return Task::done(Message::ExitViewport);
}
}
}
Task::none()
}
Message::ViewportRightPress => {
let i = self.active_tab;
self.ribbon.close_dropdown();
let mut sel = self.tabs[i].scene.selection.borrow_mut();
let Some(p) = sel.last_move_pos else {
return Task::none();
};
sel.context_menu = None;
sel.right_down = true;
sel.right_press_pos = Some(p);
sel.right_last_pos = Some(p);
sel.right_dragging = false;
Task::none()
}
Message::ViewportRightRelease => {
let i = self.active_tab;
let mut sel = self.tabs[i].scene.selection.borrow_mut();
let Some(_p) = sel.last_move_pos else {
return Task::none();
};
if sel.right_down {
if !sel.right_dragging {
sel.context_menu = sel.last_move_pos;
sel.draworder_submenu = false;
}
sel.right_down = false;
sel.right_press_pos = None;
sel.right_last_pos = None;
sel.right_dragging = false;
}
Task::none()
}
Message::ViewportMiddlePress => {
let i = self.active_tab;
self.ribbon.close_dropdown();
let now = Instant::now();
let is_double = {
let sel = self.tabs[i].scene.selection.borrow();
sel.middle_last_press_time
.map(|t| now.duration_since(t).as_millis() < 300)
.unwrap_or(false)
};
{
let mut sel = self.tabs[i].scene.selection.borrow_mut();
let Some(p) = sel.last_move_pos else {
return Task::none();
};
sel.middle_down = true;
sel.middle_last_pos = Some(p);
sel.middle_last_press_time = Some(now);
}
if is_double {
self.tabs[i].scene.fit_all();
self.command_line.push_output("Zoom Extents");
}
Task::none()
}
Message::ViewportMiddleRelease => {
let i = self.active_tab;
let mut sel = self.tabs[i].scene.selection.borrow_mut();
sel.middle_down = false;
sel.middle_last_pos = None;
Task::none()
}
Message::ViewportScroll(delta) => {
let s = match delta {
mouse::ScrollDelta::Lines { y, .. } => y,
mouse::ScrollDelta::Pixels { y, .. } => y * 0.01,
};
let i = self.active_tab;
let cursor = self.tabs[i].scene.selection.borrow().last_move_pos;
let (vw, vh) = self.tabs[i].scene.selection.borrow().vp_size;
let bounds = iced::Rectangle {
x: 0.0,
y: 0.0,
width: vw,
height: vh,
};
if self.tabs[i].scene.active_viewport.is_some() {
// In MSPACE: zoom the active viewport's model-space view,
// keeping the model point under the cursor stationary.
let cursor_paper = cursor.map(|cp| {
let pt = self.tabs[i]
.scene
.camera
.borrow()
.pick_on_target_plane(cp, bounds);
glam::Vec2::new(pt.x, pt.y)
});
self.tabs[i].scene.zoom_active_viewport(s, cursor_paper);
// Bump so the GPU re-uploads the viewport's re-culled wire
// set after zooming inside it.
self.tabs[i].scene.camera_generation += 1;
} else {
// Model space: zoom about the cursor within the active
// tile so the point under it stays put in that pane.
let tile_b = self.tabs[i].scene.active_model_tile_bounds(vw, vh);
let mut cam = self.tabs[i].scene.camera.borrow_mut();
if let Some(cursor) = cursor {
let local = iced::Point {
x: cursor.x - tile_b.x,
y: cursor.y - tile_b.y,
};
let tb = iced::Rectangle {
x: 0.0,
y: 0.0,
width: tile_b.width,
height: tile_b.height,
};
cam.zoom_about_point(local, tb, s);
} else {
cam.zoom(s);
}
drop(cam);
self.tabs[i].scene.camera_generation += 1;
}
Task::none()
}
Message::ViewportClick => {
let i = self.active_tab;
let rot = self.tabs[i].scene.active_view_rotation_mat();
let (vw, vh) = self.tabs[i].scene.selection.borrow().vp_size;
// The ViewCube draws in the top-right of whichever area
// owns it: the full canvas in model space, or the active
// viewport's screen rectangle in a paper layout. Map the
// cursor into that area before hit-testing so paper-space
// picks line up with the gizmo.
let (cx, cy, w, h) = match self
.tabs[i]
.scene
.active_viewport
.and_then(|hndl| self.tabs[i].scene.viewport_screen_rect(hndl, (vw, vh)))
{
Some(rect) => (
self.cursor_pos.x - rect.x,
self.cursor_pos.y - rect.y,
rect.width,
rect.height,
),
None => {
// Model layout: hit-test within the active tile.
let tb = self.tabs[i].scene.active_model_tile_bounds(vw, vh);
(
self.cursor_pos.x - tb.x,
self.cursor_pos.y - tb.y,
tb.width,
tb.height,
)
}
};
if let Some(region) = scene::hit_test(cx, cy, w, h, rot, VIEWCUBE_PX) {
return Task::done(Message::ViewCubeSnap(region));
}
Task::none()
}
Message::WindowResized(w, h) => {
self.vp_size = ((w - 440.0).max(200.0), h);
Task::none()
}
Message::ViewCubeSnap(region) => {
let i = self.active_tab;
let mut region = region;
// "Already there → flip to opposite" check: compare the
// current gaze direction with the region's target gaze.
let target_dir = region.snap_direction();
let cur_dir = {
let cam = self.tabs[i].scene.camera.borrow();
cam.rotation * glam::Vec3::Z
};
if cur_dir.dot(target_dir) > 0.9999 {
region = region.opposite();
}
let eye_dir = region.snap_direction();
if self.tabs[i].scene.active_viewport.is_some() {
self.tabs[i]
.scene
.snap_active_viewport_to_direction(eye_dir);
} else {
let mut cam = self.tabs[i].scene.camera.borrow_mut();
cam.snap_to_direction(eye_dir);
}
self.tabs[i].scene.camera_generation += 1;
self.command_line
.push_output(&format!("View: {}", region.label()));
Task::none()
}
Message::GripDwellTick => {
let i = self.active_tab;
// Reuse the move-time logic — `p` is the last cursor
// position the viewport saw, which is also what the
// hover state was last set with.
let p = self.tabs[i]
.scene
.selection
.borrow()
.last_move_pos
.unwrap_or(self.cursor_pos);
self.update_grip_hover(i, p);
Task::none()
}
Message::GripMenuPick(idx) => {
let i = self.active_tab;
let Some(popup) = self.grip_popup.take() else {
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::object::GripMenuAction;
if matches!(
item.action,
GripMenuAction::Stretch
| GripMenuAction::MoveWithLeader
| GripMenuAction::MoveIndependent
) {
// 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_grips
.iter()
.find(|g| g.id == popup.grip_id)
{
// "Move with Leader" drags the whole multileader; the
// others move just the picked grip.
let (grip_id, is_translate) =
if matches!(item.action, GripMenuAction::MoveWithLeader) {
(crate::entities::multileader::MOVE_ALL_GRIP, true)
} else {
(popup.grip_id, g.is_midpoint)
};
self.tabs[i].active_grip = Some(GripEdit {
handle: popup.handle,
grip_id,
is_translate,
origin_world: g.world,
last_world: 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(super::GripPendingValue {
handle: popup.handle,
grip_id: popup.grip_id,
action: item.action,
label,
});
self.command_line.push_info(&format!("{label}:"));
return self.focus_cmd_input();
}
// One-shot action — apply immediately.
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 {
acadrust::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 let Some(entity) = self
.tabs[i]
.scene
.document
.get_entity_mut(popup.handle)
{
entity.apply_grip_menu(popup.grip_id, item.action);
}
self.tabs[i].scene.bump_geometry();
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_grips.iter().find(|g| g.id == new_gid)
{
self.tabs[i].active_grip = Some(GripEdit {
handle: popup.handle,
grip_id: new_gid,
is_translate: false,
origin_world: g.world,
last_world: g.world,
});
self.grip_add_provisional = Some((popup.handle, new_gid));
}
}
Task::none()
}
// ── Snap / mode toggles ───────────────────────────────────────
Message::ToggleSnapEnabled => {
self.snapper.toggle_global();
Task::none()
}
Message::ToggleGridSnap => {
self.snapper.toggle(crate::snap::SnapType::Grid);
Task::none()
}
Message::ToggleGrid => {
self.show_grid ^= true;
Task::none()
}
Message::ToggleOrtho => {
self.ortho_mode ^= true;
if self.ortho_mode {
self.polar_mode = false;
}
Task::none()
}
Message::ToggleLineweightDisplay => {
let i = self.active_tab;
if i < self.tabs.len() {
let h = &mut self.tabs[i].scene.document.header;
h.lineweight_display = !h.lineweight_display;
// No retessellate — the wire shader reads the flag from uniforms.
self.tabs[i].dirty = true;
}
Task::none()
}
Message::TogglePolar => {
self.polar_mode ^= true;
if self.polar_mode {
self.ortho_mode = false;
}
Task::none()
}
Message::ToggleDynInput => {
self.dyn_input ^= true;
Task::none()
}
Message::ToggleViewCube => {
self.show_viewcube ^= true;
Task::none()
}
Message::ToggleProperties => {
self.show_properties ^= true;
Task::none()
}
Message::ToggleFileTabs => {
self.show_file_tabs ^= true;
Task::none()
}
Message::ToggleLayoutTabs => {
self.show_layout_tabs ^= true;
Task::none()
}
Message::ToggleOTrack => {
self.snapper.otrack_enabled ^= true;
if !self.snapper.otrack_enabled {
self.snapper.clear_tracking();
}
Task::none()
}
Message::SetPolarAngle(deg) => {
self.polar_increment_deg = deg;
self.polar_mode = true;
self.ortho_mode = false;
Task::none()
}
Message::SetAnnotationScale(scale) => {
self.scale_popup_open = false;
if let Some(tab) = self.tabs.get_mut(self.active_tab) {
tab.scene.annotation_scale = scale;
tab.scene.bump_geometry();
}
Task::none()
}
Message::SetViewportScale(scale) => {
self.scale_popup_open = false;
if let Some(tab) = self.tabs.get_mut(self.active_tab) {
tab.scene.set_viewport_scale(scale);
}
Task::none()
}
Message::ToggleScalePopup => {
self.scale_popup_open ^= true;
Task::none()
}
Message::CloseScalePopup => {
self.scale_popup_open = false;
Task::none()
}
Message::ToggleStatusBarMenu => {
self.statusbar_menu_open ^= true;
Task::none()
}
Message::CloseStatusBarMenu => {
self.statusbar_menu_open = false;
Task::none()
}
Message::ToggleStatusPill(pill) => {
// Keep the menu open so several pills can be toggled in a row.
self.statusbar_config.toggle(pill);
Task::none()
}
Message::ToggleCleanScreen => {
self.clean_screen ^= true;
Task::none()
}
Message::ToggleTransparencyDisplay => {
let i = self.active_tab;
if i < self.tabs.len() {
// No retessellate — the wire shader reads the flag from uniforms.
self.tabs[i].scene.transparency_display ^= true;
}
Task::none()
}
Message::ToggleQuickProperties => {
self.quick_properties ^= true;
Task::none()
}
Message::ToggleSelectionCycling => {
self.selection_cycling ^= true;
self.cycle_candidates = None;
self.tabs[self.active_tab].scene.set_hover_highlight(None);
Task::none()
}
Message::CycleSelect(handle) => {
// Add the picked object to the current selection (accumulate).
self.cycle_candidates = None;
let i = self.active_tab;
self.tabs[i].scene.set_hover_highlight(None);
self.tabs[i].scene.select_entity(handle, false);
self.tabs[i].scene.expand_selection_for_groups(&[handle]);
self.refresh_properties();
Task::none()
}
Message::CycleHover(handle) => {
let i = self.active_tab;
self.tabs[i].scene.set_hover_highlight(handle);
Task::none()
}
Message::CycleHoverExit(handle) => {
// Only clear if another row hasn't already taken the highlight;
// enter/exit can fire out of order when moving between rows.
let i = self.active_tab;
if self.tabs[i].scene.hover_highlight == Some(handle) {
self.tabs[i].scene.set_hover_highlight(None);
}
Task::none()
}
Message::CycleCancel => {
self.cycle_candidates = None;
self.tabs[self.active_tab].scene.set_hover_highlight(None);
Task::none()
}
Message::ToggleSelectionFilterPopup => {
self.selection_filter_popup_open ^= true;
Task::none()
}
Message::CloseSelectionFilterPopup => {
self.selection_filter_popup_open = false;
Task::none()
}
Message::ToggleSelectionFilterType(name) => {
let f = &mut self.tabs[self.active_tab].scene.selection_filter;
if !f.remove(&name) {
f.insert(name);
}
Task::none()
}
Message::ToggleUnitsPopup => {
self.units_popup_open ^= true;
Task::none()
}
Message::CloseUnitsPopup => {
self.units_popup_open = false;
Task::none()
}
Message::SetDrawingUnits(code) => {
self.units_popup_open = false;
let i = self.active_tab;
self.tabs[i].scene.document.header.insertion_units = code;
self.tabs[i].dirty = true;
Task::none()
}
Message::ToggleIsolatePopup => {
self.isolate_popup_open ^= true;
Task::none()
}
Message::CloseIsolatePopup => {
self.isolate_popup_open = false;
Task::none()
}
Message::ToggleSnap(t) => {
self.snapper.toggle(t);
Task::none()
}
Message::ToggleSnapPopup => {
self.snap_popup_open ^= true;
Task::none()
}
Message::CloseSnapPopup => {
self.snap_popup_open = false;
Task::none()
}
Message::SnapSelectAll => {
self.snapper.enable_all();
Task::none()
}
Message::SnapClearAll => {
self.snapper.disable_all();
Task::none()
}
// ── Ribbon dropdowns ──────────────────────────────────────────
Message::ToggleRibbonDropdown(id) => {
self.ribbon.toggle_dropdown(&id);
Task::none()
}
Message::CloseRibbonDropdown => {
self.ribbon.close_dropdown();
Task::none()
}
Message::DropdownSelectItem { dropdown_id, cmd } => {
self.ribbon.select_dropdown_item(dropdown_id, cmd);
self.ribbon.activate_tool(cmd);
self.dispatch_command(cmd)
}
Message::DeleteSelected => {
// In the MText preview, Delete removes text at the caret.
if self.mtext_editor.as_ref().is_some_and(|e| e.show_preview) {
self.mtext_delete();
return Task::none();
}
let i = self.active_tab;
self.tabs[i].scene.selection.borrow_mut().context_menu = None;
let handles: Vec<_> = self.tabs[i].scene.selected.iter().cloned().collect();
if !handles.is_empty() {
self.push_undo_snapshot(i, "ERASE");
self.tabs[i].scene.erase_entities(&handles);
self.tabs[i].dirty = true;
self.refresh_properties();
}
Task::none()
}
Message::SetShiftDown(down) => {
self.shift_down = down;
Task::none()
}
// ── In-place MText editor ───────────────────────────────────
Message::MTextEdit(action) => {
if let Some(ed) = self.mtext_editor.as_mut() {
ed.content.perform(action);
}
self.rebuild_mtext_preview();
Task::none()
}
Message::MTextFmt(kind) => {
self.mtext_apply_fmt(kind);
Task::none()
}
Message::MTextHeight(s) => {
if let Some(ed) = self.mtext_editor.as_mut() {
ed.height = s;
}
self.rebuild_mtext_preview();
Task::none()
}
Message::MTextColor(aci) => {
if let Some(ed) = self.mtext_editor.as_mut() {
ed.color_aci = aci;
}
self.rebuild_mtext_preview();
Task::none()
}
Message::MTextStyle(s) => {
if let Some(ed) = self.mtext_editor.as_mut() {
ed.style = s;
}
self.rebuild_mtext_preview();
Task::none()
}
Message::MTextFont(f) => {
if let Some(ed) = self.mtext_editor.as_mut() {
ed.font = if f == "[Style default]" { String::new() } else { f };
}
self.rebuild_mtext_preview();
Task::none()
}
Message::MTextOblique(s) => {
if let Some(ed) = self.mtext_editor.as_mut() {
ed.oblique = s;
}
self.rebuild_mtext_preview();
Task::none()
}
Message::MTextWidth(s) => {
if let Some(ed) = self.mtext_editor.as_mut() {
ed.width = s;
}
self.rebuild_mtext_preview();
Task::none()
}
Message::MTextCharSpace(s) => {
if let Some(ed) = self.mtext_editor.as_mut() {
ed.char_space = s;
}
self.rebuild_mtext_preview();
Task::none()
}
Message::MTextJustify(ap) => {
if let Some(ed) = self.mtext_editor.as_mut() {
ed.attachment = ap;
}
self.rebuild_mtext_preview();
Task::none()
}
Message::MTextAlign(a) => {
self.mtext_apply_align(a);
Task::none()
}
Message::MTextLineSpacing(f) => {
if let Some(ed) = self.mtext_editor.as_mut() {
ed.line_spacing = f;
}
self.rebuild_mtext_preview();
Task::none()
}
Message::MTextShowPreview(on) => {
if let Some(ed) = self.mtext_editor.as_mut() {
ed.show_preview = on;
}
self.rebuild_mtext_preview();
// Focus the text area when switching to Edit so the caret
// shows and typing/clicking edits immediately.
if on {
Task::none()
} else {
iced::widget::operation::focus(iced::widget::Id::new(
super::view::MTEXT_TEXT_ID,
))
}
}
Message::MTextSelStart(off) => {
if let Some(ed) = self.mtext_editor.as_mut() {
ed.sel_anchor = off;
ed.sel = Some((off, off));
ed.caret = off;
ed.caret_blink_on = true;
}
Task::none()
}
Message::MTextSelTo(off) => {
if let Some(ed) = self.mtext_editor.as_mut() {
let a = ed.sel_anchor;
ed.sel = Some((a.min(off), a.max(off)));
ed.caret = off;
ed.caret_blink_on = true;
}
Task::none()
}
Message::MTextCaretMove(d) => {
self.mtext_caret_move(d);
Task::none()
}
Message::MTextCaretBlink => {
if let Some(ed) = self.mtext_editor.as_mut() {
ed.caret_blink_on = !ed.caret_blink_on;
}
Task::none()
}
Message::MTextOk => {
self.mtext_commit();
Task::none()
}
Message::MTextCancel => {
self.mtext_cancel();
Task::none()
}
Message::TextInlineInput(s) => {
if let Some(ed) = self.text_inline.as_mut() {
ed.value = s;
}
Task::none()
}
Message::TextInlineOk => {
self.text_inline_commit();
Task::none()
}
Message::DrawOrderSubmenuToggle => {
let i = self.active_tab;
let mut sel = self.tabs[i].scene.selection.borrow_mut();
sel.draworder_submenu = !sel.draworder_submenu;
Task::none()
}
Message::DrawOrderPickRef(above) => {
let i = self.active_tab;
self.tabs[i].scene.selection.borrow_mut().context_menu = None;
let to_move: Vec<_> = self.tabs[i].scene.selected.iter().cloned().collect();
if to_move.is_empty() {
self.command_line
.push_error("DRAWORDER: select entities first.");
} else {
use crate::command::CadCommand;
let cmd = super::commands::DrawOrderRefCommand::new(to_move, above);
self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd));
}
Task::none()
}
Message::SelectSimilar => {
let i = self.active_tab;
self.tabs[i].scene.selection.borrow_mut().context_menu = None;
let added = self.tabs[i].scene.select_similar();
self.command_line
.push_output(&format!("Select Similar: {} added.", added));
self.refresh_properties();
Task::none()
}
Message::QSelectOpen => {
let i = self.active_tab;
self.tabs[i].scene.selection.borrow_mut().context_menu = None;
// Seed the type filter from the first selected entity so a
// right-click → Quick Select on a known object opens the
// panel pre-tuned to that entity's type. Property defaults
// to "(Any property)" so the user immediately picks what
// they want to compare.
let mut type_filter: Option<String> = None;
if let Some(&h) = self.tabs[i].scene.selected.iter().next() {
if let Some(e) = self.tabs[i].scene.document.get_entity(h) {
use crate::entities::traits::entity_type_name;
type_filter = Some(entity_type_name(e).to_string());
}
}
self.qselect = Some(crate::app::QSelectState {
type_filter,
property: None,
operator: crate::app::QSelectOp::Eq,
value: String::new(),
append: false,
});
Task::none()
}
Message::QSelectClose => {
self.qselect = None;
Task::none()
}
Message::QSelectSetType(t) => {
if let Some(state) = self.qselect.as_mut() {
// Drop the property when it no longer applies to the
// chosen type: type-specific fields like `start_x`
// would otherwise stay selected but never match.
let kept_property = state.property.clone().and_then(|p| {
let i = self.active_tab;
let props = self.tabs[i].scene.qselect_properties(t.as_deref());
if props.iter().any(|(f, _)| f == &p.field) {
Some(p)
} else {
None
}
});
state.type_filter = t;
state.property = kept_property;
}
Task::none()
}
Message::QSelectSetProperty(p) => {
if let Some(state) = self.qselect.as_mut() {
state.property = p;
}
Task::none()
}
Message::QSelectSetOperator(op) => {
if let Some(state) = self.qselect.as_mut() {
state.operator = op;
}
Task::none()
}
Message::QSelectSetValue(v) => {
if let Some(state) = self.qselect.as_mut() {
state.value = v;
}
Task::none()
}
Message::QSelectSetAppend(b) => {
if let Some(state) = self.qselect.as_mut() {
state.append = b;
}
Task::none()
}
Message::QSelectApply => {
let Some(state) = self.qselect.take() else {
return Task::none();
};
let i = self.active_tab;
let matched = self.tabs[i].scene.qselect(
state.type_filter.as_deref(),
state.property.as_ref().map(|p| p.field.as_str()),
state.operator,
&state.value,
state.append,
);
self.command_line
.push_output(&format!("QSELECT: {} object(s) selected.", matched));
self.refresh_properties();
Task::none()
}
// ── Properties panel messages ─────────────────────────────────
Message::PropSelectionGroupChanged(group) => {
self.tabs[self.active_tab].properties.selected_group = Some(group);
self.refresh_properties();
Task::none()
}
Message::RibbonLayerChanged(layer) => {
let i = self.active_tab;
self.ribbon.close_dropdown();
let handles = self.property_target_handles(i);
if handles.is_empty() {
// 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(acadrust::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 selection; leave the creation default alone
// (matches AutoCAD; "Make current" is a separate action).
self.push_undo_snapshot(i, "CHPROP");
for handle in handles {
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) {
crate::scene::dispatch::apply_common_prop(entity, "layer", &layer);
}
}
self.tabs[i].dirty = true;
self.ribbon.active_layer = layer;
self.refresh_properties();
}
Task::none()
}
Message::RibbonColorChanged(color) => {
let i = self.active_tab;
self.ribbon.prop_color_palette_open = false;
self.ribbon.close_dropdown();
let handles = self.property_target_handles(i);
if handles.is_empty() {
// 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.push_undo_snapshot(i, "CHPROP");
for handle in handles {
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) {
crate::scene::dispatch::apply_color(entity, color);
}
}
self.tabs[i].dirty = true;
self.ribbon.active_color = color;
self.refresh_properties();
}
Task::none()
}
Message::RibbonColorPaletteToggle => {
self.ribbon.prop_color_palette_open ^= true;
Task::none()
}
Message::RibbonLinetypeChanged(lt) => {
let i = self.active_tab;
self.ribbon.close_dropdown();
let handles = self.property_target_handles(i);
if handles.is_empty() {
// 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(&lt))
.map(|x| x.handle)
.unwrap_or(acadrust::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 {
self.push_undo_snapshot(i, "CHPROP");
for handle in handles {
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) {
crate::scene::dispatch::apply_common_prop(entity, "linetype", &lt);
}
}
self.tabs[i].dirty = true;
self.ribbon.active_linetype = lt;
self.refresh_properties();
}
Task::none()
}
Message::RibbonLineweightChanged(lw) => {
let i = self.active_tab;
self.ribbon.close_dropdown();
let handles = self.property_target_handles(i);
if handles.is_empty() {
// Persist into the tab's header (CELWEIGHT). #21.
self.tabs[i].scene.document.header.current_line_weight = lw.value();
self.tabs[i].dirty = true;
self.ribbon.active_lineweight = lw;
} else {
self.push_undo_snapshot(i, "CHPROP");
for handle in handles {
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) {
crate::scene::dispatch::apply_line_weight(entity, lw);
}
}
self.tabs[i].dirty = true;
self.ribbon.active_lineweight = lw;
self.refresh_properties();
}
Task::none()
}
Message::RibbonStyleChanged { key, name } => {
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 => {
self.ribbon.active_mleader_style = name.clone();
let i = self.active_tab;
self.tabs[i].active_mleader_style = name;
}
StyleKey::TableStyle => {
self.ribbon.active_table_style = name;
}
}
Task::none()
}
Message::PropLayerChanged(layer) => {
let i = self.active_tab;
let handles = self.property_target_handles(i);
if !handles.is_empty() {
self.push_undo_snapshot(i, "CHPROP");
for handle in handles {
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) {
crate::scene::dispatch::apply_common_prop(entity, "layer", &layer);
}
}
self.tabs[i].dirty = true;
self.refresh_properties();
}
Task::none()
}
Message::PropColorChanged(color) => {
let i = self.active_tab;
let handles = self.property_target_handles(i);
if !handles.is_empty() {
self.push_undo_snapshot(i, "CHPROP");
for handle in handles {
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) {
crate::scene::dispatch::apply_color(entity, color);
}
}
self.tabs[i].properties.color_picker_open = false;
self.tabs[i].properties.color_palette_open = false;
self.tabs[i].dirty = true;
self.refresh_properties();
}
Task::none()
}
Message::PropLwChanged(lw) => {
let i = self.active_tab;
let handles = self.property_target_handles(i);
if !handles.is_empty() {
self.push_undo_snapshot(i, "CHPROP");
for handle in handles {
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) {
crate::scene::dispatch::apply_line_weight(entity, lw);
}
}
self.tabs[i].dirty = true;
self.refresh_properties();
}
Task::none()
}
Message::PropLinetypeChanged(lt) => {
let i = self.active_tab;
let handles = self.property_target_handles(i);
if !handles.is_empty() {
self.push_undo_snapshot(i, "CHPROP");
for handle in handles {
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) {
crate::scene::dispatch::apply_common_prop(entity, "linetype", &lt);
}
}
self.tabs[i].dirty = true;
self.refresh_properties();
}
Task::none()
}
Message::PropHatchPatternChanged(name) => {
let i = self.active_tab;
let handles = self.property_target_handles(i);
if !handles.is_empty() {
use crate::scene::hatch_patterns;
if let Some(entry) = hatch_patterns::find(&name) {
self.push_undo_snapshot(i, "HATCHEDIT");
for handle in handles {
if let Some(acadrust::EntityType::Hatch(dxf)) =
self.tabs[i].scene.document.get_entity_mut(handle)
{
dxf.pattern = hatch_patterns::build_dxf_pattern(entry);
dxf.is_solid = matches!(
entry.gpu,
crate::scene::hatch_model::HatchPattern::Solid
);
}
if let Some(model) = self.tabs[i].scene.hatches.get_mut(&handle) {
model.pattern = entry.gpu.clone();
model.name = name.clone();
}
}
self.tabs[i].dirty = true;
self.refresh_properties();
}
}
Task::none()
}
Message::PropBoolToggle(field) => {
let i = self.active_tab;
let handles = self.property_target_handles(i);
if !handles.is_empty() {
self.push_undo_snapshot(i, "CHPROP");
for handle in handles {
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) {
match field {
"invisible" => crate::scene::dispatch::toggle_invisible(entity),
_ => {
crate::scene::dispatch::apply_geom_prop(entity, field, "toggle")
}
}
}
}
self.tabs[i].dirty = true;
self.refresh_properties();
}
Task::none()
}
Message::PropGeomChoiceChanged { field, value } => {
let i = self.active_tab;
let handles = self.property_target_handles(i);
if !handles.is_empty() {
self.push_undo_snapshot(i, "CHPROP");
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 {
for handle in &handles {
if let Some(acadrust::EntityType::Viewport(vp)) =
self.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();
}
}
}
} else 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 {
for handle in &handles {
if let Some(acadrust::EntityType::Viewport(vp)) =
self.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 {
for handle in handles {
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle)
{
crate::scene::dispatch::apply_geom_prop(entity, field, &value);
}
}
}
self.tabs[i].dirty = true;
self.refresh_properties();
}
Task::none()
}
Message::PropGeomInput { field, value } => {
self.tabs[self.active_tab]
.properties
.edit_buf
.insert(field.to_string(), value);
Task::none()
}
Message::PropGeomCommit(field) => {
let i = self.active_tab;
let handles = self.property_target_handles(i);
if !handles.is_empty() {
if let Some(val) = self.tabs[i].properties.edit_buf.remove(field) {
self.push_undo_snapshot(i, "CHPROP");
if field == "frozen_layers" {
// Resolve layer names → handles, then apply to viewports.
let layer_handles: Vec<acadrust::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(acadrust::EntityType::Viewport(vp)) =
self.tabs[i].scene.document.get_entity_mut(handle)
{
vp.frozen_layers = layer_handles.clone();
}
}
} else {
for handle in handles {
if let Some(entity) =
self.tabs[i].scene.document.get_entity_mut(handle)
{
match field {
"linetype_scale" | "transparency" => {
crate::scene::dispatch::apply_common_prop(
entity, field, &val,
);
}
_ => {
crate::scene::dispatch::apply_geom_prop(
entity, field, &val,
);
}
}
}
}
}
self.tabs[i].dirty = true;
self.refresh_properties();
}
}
Task::none()
}
Message::PropColorPickerToggle => {
let i = self.active_tab;
self.tabs[i].properties.color_picker_open =
!self.tabs[i].properties.color_picker_open;
if self.tabs[i].properties.color_picker_open {
self.tabs[i].properties.color_palette_open = false;
}
Task::none()
}
Message::PropColorPaletteToggle => {
self.tabs[self.active_tab].properties.color_palette_open =
!self.tabs[self.active_tab].properties.color_palette_open;
Task::none()
}
Message::LayoutSwitch(name) => {
let i = self.active_tab;
let going_to_paper = name != "Model";
// Persist the camera of the layout we're leaving BEFORE switching
// so returning to it restores where the user left off (the
// periodic sync only fires on a tick, which may not have run
// since the last pan/zoom).
self.tabs[i].scene.sync_camera_to_document();
self.tabs[i].last_synced_camera_gen = self.tabs[i].scene.camera_generation;
// Cancel any pending rename/context-menu and active viewport when switching.
self.layout_rename_state = None;
self.layout_context_menu = None;
self.tabs[i].scene.active_viewport = None;
self.tabs[i].scene.set_current_layout(name);
self.tabs[i].scene.deselect_all();
self.tabs[i].scene.restore_saved_camera();
self.tabs[i].last_synced_camera_gen = self.tabs[i].scene.camera_generation;
if going_to_paper {
if let Some(idx) = self.ribbon.layout_module_index() {
self.ribbon.select(idx);
}
} else if self.ribbon.active_is_layout() {
self.ribbon.select(0);
}
// Refresh VP freeze columns for the new layout.
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);
Task::none()
}
Message::LayoutCreate => {
let i = self.active_tab;
// Find a unique name (e.g. Layout2, Layout3, ...).
let existing = self.tabs[i].scene.layout_names();
let mut idx = existing.len();
let new_name = loop {
let candidate = format!("Layout{}", idx);
if !existing.contains(&candidate) {
break candidate;
}
idx += 1;
};
self.push_undo_snapshot(i, "LAYOUT");
match self.tabs[i].scene.document.add_layout(&new_name) {
Ok(_) => {
// Override the acadrust default limits (12×9 imperial) with A4 landscape.
for obj in self.tabs[i].scene.document.objects.values_mut() {
if let acadrust::objects::ObjectType::Layout(l) = obj {
if l.name == new_name {
l.min_limits = (0.0, 0.0);
l.max_limits = (297.0, 210.0);
l.min_extents = (0.0, 0.0, 0.0);
l.max_extents = (297.0, 210.0, 0.0);
break;
}
}
}
self.tabs[i].scene.current_layout = new_name.clone();
// Safety net — `add_layout` already creates the overall
// sheet viewport; this covers any path that doesn't.
self.tabs[i].scene.ensure_sheet_viewport(&new_name);
self.tabs[i].scene.deselect_all();
self.tabs[i].scene.fit_all();
if let Some(idx) = self.ribbon.layout_module_index() {
self.ribbon.select(idx);
}
self.command_line.push_output(&format!(
"Layout \"{new_name}\" created — use MVIEW to add a viewport"
));
self.tabs[i].dirty = true;
}
Err(e) => self
.command_line
.push_error(&format!("Failed to create layout: {e}")),
}
Task::none()
}
Message::LayoutDelete(name) => {
let i = self.active_tab;
self.push_undo_snapshot(i, "LAYOUT DEL");
if self.tabs[i].scene.delete_layout(&name) {
self.layout_context_menu = None;
self.layout_rename_state = None;
// If we fell back to Model space, update ribbon.
if self.tabs[i].scene.current_layout == "Model"
&& self.ribbon.active_is_layout()
{
self.ribbon.select(0);
}
self.command_line
.push_output(&format!("Layout \"{name}\" silindi"));
self.tabs[i].dirty = true;
}
Task::none()
}
Message::LayoutRenameStart(name) => {
if name != "Model" {
self.layout_rename_state = Some((name.clone(), name));
self.layout_context_menu = None;
}
Task::none()
}
Message::LayoutRenameEdit(val) => {
if let Some((orig, _)) = &self.layout_rename_state {
let orig = orig.clone();
self.layout_rename_state = Some((orig, val));
}
Task::none()
}
Message::LayoutRenameCommit => {
if let Some((orig, new_name)) = self.layout_rename_state.take() {
let new_name = new_name.trim().to_string();
if !new_name.is_empty() && new_name != orig {
let i = self.active_tab;
let exists = self.tabs[i]
.scene
.layout_names()
.iter()
.any(|n| *n == new_name);
if exists {
self.command_line
.push_error(&format!("\"{}\" name already in use", new_name));
} else {
self.push_undo_snapshot(i, "LAYOUT RENAME");
self.tabs[i].scene.rename_layout(&orig, &new_name);
if self.tabs[i].scene.current_layout == orig {
self.tabs[i].scene.current_layout = new_name.clone();
}
self.tabs[i].dirty = true;
self.command_line
.push_output(&format!("Layout \"{orig}\"\"{new_name}\""));
}
}
}
Task::none()
}
Message::LayoutRenameCancel => {
self.layout_rename_state = None;
Task::none()
}
Message::LayoutContextMenu(name) => {
if name != "Model" {
self.layout_context_menu = Some(name);
}
Task::none()
}
Message::LayoutContextMenuClose => {
self.layout_context_menu = None;
Task::none()
}
// ── Layout Manager Panel ──────────────────────────────────────────
Message::LayoutManagerOpen => {
let i = self.active_tab;
let current = self.tabs[i].scene.current_layout.clone();
self.layout_manager_selected = current.clone();
self.layout_manager_rename_buf = if current == "Model" {
String::new()
} else {
current
};
if let Some(id) = self.layout_manager_window {
return window::gain_focus(id);
}
let (id, task) = window::open(window::Settings {
size: iced::Size::new(640.0, 320.0),
resizable: true,
level: window::Level::AlwaysOnTop,
..Default::default()
});
self.layout_manager_window = Some(id);
task.map(|_| Message::Noop)
}
Message::LayoutManagerClose => {
if let Some(id) = self.layout_manager_window.take() {
window::close(id)
} else {
Task::none()
}
}
Message::LayoutManagerSelect(name) => {
self.layout_manager_rename_buf = if name == "Model" {
String::new()
} else {
name.clone()
};
self.layout_manager_selected = name;
Task::none()
}
Message::LayoutManagerRenameBuf(s) => {
self.layout_manager_rename_buf = s;
Task::none()
}
Message::LayoutManagerRenameCommit => {
let i = self.active_tab;
let old_name = self.layout_manager_selected.clone();
let new_name = self.layout_manager_rename_buf.trim().to_string();
if old_name == "Model" {
self.command_line
.push_error("Cannot rename the Model layout.");
} else if new_name.is_empty() {
self.command_line.push_error("Layout name cannot be empty.");
} else if new_name == old_name {
// no-op
} else {
self.push_undo_snapshot(i, "LAYOUT RENAME");
self.tabs[i].scene.rename_layout(&old_name, &new_name);
if self.tabs[i].scene.current_layout == old_name {
self.tabs[i].scene.current_layout = new_name.clone();
}
self.layout_manager_selected = new_name.clone();
self.tabs[i].dirty = true;
self.command_line
.push_output(&format!("Layout renamed: '{old_name}' → '{new_name}'"));
}
Task::none()
}
Message::LayoutManagerNew => {
let i = self.active_tab;
let existing = self.tabs[i].scene.layout_names();
let n = (1usize..)
.find(|n| !existing.contains(&format!("Layout{n}")))
.unwrap_or(1);
let name = format!("Layout{n}");
self.push_undo_snapshot(i, "LAYOUT NEW");
match self.tabs[i].scene.document.add_layout(&name) {
Ok(_) => {
self.tabs[i].dirty = true;
self.layout_manager_selected = name.clone();
self.layout_manager_rename_buf = name.clone();
self.command_line
.push_output(&format!("Layout '{name}' created."));
}
Err(e) => self.command_line.push_error(&format!("LAYOUT: {e}")),
}
Task::none()
}
Message::LayoutManagerDelete => {
let i = self.active_tab;
let name = self.layout_manager_selected.clone();
if name == "Model" {
self.command_line
.push_error("Cannot delete the Model layout.");
} else {
self.push_undo_snapshot(i, "LAYOUT DELETE");
self.tabs[i].scene.delete_layout(&name);
self.tabs[i].dirty = true;
// Switch to Model if active layout was deleted.
if self.tabs[i].scene.current_layout == name {
self.tabs[i].scene.current_layout = "Model".to_string();
self.tabs[i].scene.bump_geometry();
}
self.layout_manager_selected = "Model".to_string();
self.layout_manager_rename_buf = String::new();
self.command_line
.push_output(&format!("Layout '{name}' deleted."));
}
Task::none()
}
Message::LayoutManagerMoveLeft => {
let i = self.active_tab;
let name = self.layout_manager_selected.clone();
if name == "Model" {
return Task::none();
}
let names = self.tabs[i].scene.layout_names();
// Find position among paper layouts only.
let paper: Vec<&str> = names.iter().skip(1).map(|s| s.as_str()).collect();
if let Some(pos) = paper.iter().position(|&n| n == name) {
if pos > 0 {
self.push_undo_snapshot(i, "LAYOUT REORDER");
self.tabs[i].scene.swap_layout_order(&name, paper[pos - 1]);
self.tabs[i].dirty = true;
}
}
Task::none()
}
Message::LayoutManagerMoveRight => {
let i = self.active_tab;
let name = self.layout_manager_selected.clone();
if name == "Model" {
return Task::none();
}
let names = self.tabs[i].scene.layout_names();
let paper: Vec<&str> = names.iter().skip(1).map(|s| s.as_str()).collect();
if let Some(pos) = paper.iter().position(|&n| n == name) {
if pos + 1 < paper.len() {
self.push_undo_snapshot(i, "LAYOUT REORDER");
self.tabs[i].scene.swap_layout_order(&name, paper[pos + 1]);
self.tabs[i].dirty = true;
}
}
Task::none()
}
Message::LayoutManagerSetCurrent => {
let i = self.active_tab;
let name = self.layout_manager_selected.clone();
self.tabs[i].scene.current_layout = name.clone();
self.tabs[i].scene.bump_geometry();
self.command_line
.push_output(&format!("Switched to layout '{name}'."));
Task::none()
}
Message::SetTheme(theme) => {
self.active_theme = theme;
Task::none()
}
// ── Keyboard Shortcuts Panel ──────────────────────────────────────
Message::ShortcutsPanelOpen => {
if let Some(id) = self.shortcuts_window {
return window::gain_focus(id);
}
let (id, task) = window::open(window::Settings {
size: iced::Size::new(720.0, 520.0),
resizable: true,
level: window::Level::AlwaysOnTop,
..Default::default()
});
self.shortcuts_window = Some(id);
task.map(|_| Message::Noop)
}
Message::ShortcutsPanelClose => {
if let Some(id) = self.shortcuts_window.take() {
window::close(id)
} else {
Task::none()
}
}
// ── About window ──────────────────────────────────────────────
Message::AboutOpen => {
if let Some(id) = self.about_window {
return window::gain_focus(id);
}
let (id, task) = window::open(window::Settings {
size: iced::Size::new(340.0, 240.0),
resizable: false,
level: window::Level::AlwaysOnTop,
..Default::default()
});
self.about_window = Some(id);
task.map(|_| Message::Noop)
}
Message::AboutCopyInfo => {
let info = format!(
"Open CAD Studio v{}\nOS: {}\nArch: {}",
env!("CARGO_PKG_VERSION"),
std::env::consts::OS,
std::env::consts::ARCH,
);
iced::clipboard::write(info)
}
Message::EnterViewport(handle) => {
let i = self.active_tab;
// Clear paper-space selection before entering model space.
self.tabs[i].scene.deselect_all();
self.tabs[i].scene.active_viewport = Some(handle);
self.refresh_properties();
self.command_line.push_output("MSPACE");
Task::none()
}
Message::ExitViewport => {
let i = self.active_tab;
// Clear model-space selection before returning to paper space.
self.tabs[i].scene.deselect_all();
self.refresh_properties();
self.tabs[i].scene.active_viewport = None;
self.command_line.push_output("PSPACE");
Task::none()
}
Message::MspaceCommand => {
let i = self.active_tab;
if self.tabs[i].scene.current_layout == "Model" {
self.command_line
.push_error("MS is only available in paper space layouts.");
return Task::none();
}
if self.tabs[i].scene.active_viewport.is_some() {
// Already in MSPACE — nothing to do.
return Task::none();
}
match self.tabs[i].scene.first_user_viewport() {
Some(handle) => Task::done(Message::EnterViewport(handle)),
None => {
self.command_line
.push_error("No viewport found in this layout.");
Task::none()
}
}
}
Message::PspaceCommand => Task::done(Message::ExitViewport),
Message::Undo => {
self.undo_active_tab();
Task::none()
}
Message::Redo => {
self.redo_active_tab();
Task::none()
}
Message::UndoMany(steps) => {
self.ribbon.close_dropdown();
self.undo_steps(steps);
Task::none()
}
Message::RedoMany(steps) => {
self.ribbon.close_dropdown();
self.redo_steps(steps);
Task::none()
}
Message::Noop => Task::none(),
// ── Unsaved-changes dialog ────────────────────────────────────
Message::UnsavedDialogCancel => {
self.pending_close = None;
self.close_unsaved_dialog_window()
}
Message::UnsavedDialogDiscard => {
match self.pending_close.take() {
Some(super::PendingClose::Tab(idx)) => {
let close_win = self.close_unsaved_dialog_window();
if self.tabs.len() == 1 {
self.tab_counter += 1;
self.tabs[0] =
super::document::DocumentTab::new_drawing(self.tab_counter);
self.active_tab = 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 a fresh blank or a
// different existing tab; sync ribbon chips so
// they don't keep showing the discarded tab's
// last selection. #21.
self.sync_ribbon_layers();
self.sync_ribbon_from_selection();
return close_win;
}
Some(super::PendingClose::Quit) => {
if let Some(idx) = self.tabs.iter().position(|t| t.dirty) {
self.tabs[idx].dirty = false;
}
if self.tabs.iter().any(|t| t.dirty) {
// More dirty tabs remain — keep window open.
self.pending_close = Some(super::PendingClose::Quit);
} else {
let close_win = self.close_unsaved_dialog_window();
return Task::batch(vec![close_win, iced::exit()]);
}
}
None => {}
}
Task::none()
}
Message::UnsavedDialogSave => {
match self.pending_close.take() {
Some(super::PendingClose::Tab(idx)) => {
if let Some(path) = self.tabs[idx].current_path.clone() {
match crate::io::save(&self.tabs[idx].scene.document, &path) {
Ok(()) => {
self.command_line
.push_output(&format!("Saved: {}", path.display()));
self.tabs[idx].dirty = false;
let close_win = self.close_unsaved_dialog_window();
let close_tab = self.update(Message::TabClose(idx));
return Task::batch(vec![close_win, close_tab]);
}
Err(e) => {
// Keep dialog open for retry.
self.command_line.push_error(&format!("Save failed: {e}"));
self.pending_close = Some(super::PendingClose::Tab(idx));
}
}
} else {
// No path — close unsaved dialog, open custom Save As dialog.
self.pending_close = Some(super::PendingClose::Tab(idx));
self.save_dialog_for_unsaved = true;
let close_win = self.close_unsaved_dialog_window();
let open_save = self.open_save_dialog_window(idx);
return Task::batch([close_win, open_save]);
}
}
Some(super::PendingClose::Quit) => {
if let Some(idx) = self.tabs.iter().position(|t| t.dirty) {
if let Some(path) = self.tabs[idx].current_path.clone() {
match crate::io::save(&self.tabs[idx].scene.document, &path) {
Ok(()) => {
self.command_line
.push_output(&format!("Saved: {}", path.display()));
self.tabs[idx].dirty = false;
}
Err(e) => {
self.command_line.push_error(&format!("Save failed: {e}"));
self.pending_close = Some(super::PendingClose::Quit);
return Task::none();
}
}
} else {
// No path — close unsaved dialog, open custom Save As dialog.
self.active_tab = idx;
self.pending_close = Some(super::PendingClose::Quit);
self.save_dialog_for_unsaved = true;
let close_win = self.close_unsaved_dialog_window();
let open_save = self.open_save_dialog_window(idx);
return Task::batch([close_win, open_save]);
}
}
if self.tabs.iter().any(|t| t.dirty) {
// More dirty tabs — keep window open.
self.pending_close = Some(super::PendingClose::Quit);
} else {
let close_win = self.close_unsaved_dialog_window();
return Task::batch(vec![close_win, iced::exit()]);
}
}
None => {}
}
Task::none()
}
Message::UnsavedPickedSavePath(Some(path)) => {
let (_, version) = crate::io::parse_save_format(&self.save_dialog_format);
match self.pending_close.take() {
Some(super::PendingClose::Tab(idx)) => {
match crate::io::save_as_version(
&self.tabs[idx].scene.document,
&path,
version,
) {
Ok(()) => {
self.command_line
.push_output(&format!("Saved: {}", path.display()));
self.tabs[idx].current_path = Some(path);
self.tabs[idx].dirty = false;
return self.update(Message::TabClose(idx));
}
Err(e) => {
self.command_line.push_error(&format!("Save failed: {e}"));
self.pending_close = Some(super::PendingClose::Tab(idx));
return self.open_unsaved_dialog_window();
}
}
}
Some(super::PendingClose::Quit) => {
let i = self.active_tab;
match crate::io::save_as_version(
&self.tabs[i].scene.document,
&path,
version,
) {
Ok(()) => {
self.command_line
.push_output(&format!("Saved: {}", path.display()));
self.tabs[i].current_path = Some(path);
self.tabs[i].dirty = false;
if self.tabs.iter().any(|t| t.dirty) {
self.pending_close = Some(super::PendingClose::Quit);
return self.open_unsaved_dialog_window();
} else {
return iced::exit();
}
}
Err(e) => {
self.command_line.push_error(&format!("Save failed: {e}"));
self.pending_close = Some(super::PendingClose::Quit);
return self.open_unsaved_dialog_window();
}
}
}
None => {}
}
Task::none()
}
Message::UnsavedPickedSavePath(None) => {
// User cancelled the save-as dialog — re-open the confirmation dialog.
if self.pending_close.is_some() {
return self.open_unsaved_dialog_window();
}
Task::none()
}
// ── Page Setup ────────────────────────────────────────────────
Message::PageSetupOpen => {
let i = self.active_tab;
// Populate edit buffers from current paper limits.
let (w, h) = if let Some(((x0, y0), (x1, y1))) = self.tabs[i].scene.paper_limits() {
(x1 - x0, y1 - y0)
} else {
(297.0, 210.0) // A4 default
};
self.page_setup_w = format!("{w:.1}");
self.page_setup_h = format!("{h:.1}");
if let Some(id) = self.page_setup_window {
return window::gain_focus(id);
}
let (id, task) = window::open(window::Settings {
size: iced::Size::new(520.0, 460.0),
resizable: false,
level: window::Level::AlwaysOnTop,
..Default::default()
});
self.page_setup_window = Some(id);
task.map(|_| Message::Noop)
}
Message::PageSetupClose => {
if let Some(id) = self.page_setup_window.take() {
window::close(id)
} else {
Task::none()
}
}
Message::UpdateCheckResult(latest) => {
let Some(info) = latest else {
return Task::none();
};
self.update_notice_version = Some(info.version);
self.update_notice_body = Some(info.body);
if let Some(id) = self.update_notice_window {
return window::gain_focus(id);
}
let (id, task) = window::open(window::Settings {
// Sized for the new release-notes panel — wide enough
// for typical GitHub release headlines, tall enough for
// a meaningful scroll preview without dwarfing the app.
size: iced::Size::new(560.0, 460.0),
resizable: true,
level: window::Level::AlwaysOnTop,
..Default::default()
});
self.update_notice_window = Some(id);
task.map(|_| Message::Noop)
}
Message::UpdateNoticeClose => {
if let Some(id) = self.update_notice_window.take() {
window::close(id)
} else {
Task::none()
}
}
Message::UpdateNoticeOpenRelease => {
let _ = open::that(crate::update_check::RELEASES_PAGE);
if let Some(id) = self.update_notice_window.take() {
window::close(id)
} else {
Task::none()
}
}
Message::PageSetupWidthEdit(s) => {
self.page_setup_w = s;
Task::none()
}
Message::PageSetupHeightEdit(s) => {
self.page_setup_h = s;
Task::none()
}
Message::PageSetupPreset(name) => {
// Paper size presets defined in view.rs — mirror them here.
let sizes: &[(&str, f64, f64)] = &[
("A4 Portrait", 210.0, 297.0),
("A4 Landscape", 297.0, 210.0),
("A3 Portrait", 297.0, 420.0),
("A3 Landscape", 420.0, 297.0),
("A2 Portrait", 420.0, 594.0),
("A2 Landscape", 594.0, 420.0),
("A1 Portrait", 594.0, 841.0),
("A1 Landscape", 841.0, 594.0),
("A0 Portrait", 841.0, 1189.0),
("A0 Landscape", 1189.0, 841.0),
("Letter Portrait", 215.9, 279.4),
("Letter Landscape", 279.4, 215.9),
];
if let Some(&(_, w, h)) = sizes.iter().find(|(n, _, _)| *n == name) {
self.page_setup_w = format!("{w:.1}");
self.page_setup_h = format!("{h:.1}");
}
Task::none()
}
Message::PageSetupPlotArea(s) => {
self.page_setup_plot_area = s;
Task::none()
}
Message::PageSetupCenterToggle => {
self.page_setup_center = !self.page_setup_center;
Task::none()
}
Message::PageSetupOffsetXEdit(s) => {
self.page_setup_offset_x = s;
Task::none()
}
Message::PageSetupOffsetYEdit(s) => {
self.page_setup_offset_y = s;
Task::none()
}
Message::PageSetupRotation(s) => {
self.page_setup_rotation = s;
Task::none()
}
Message::PageSetupScale(s) => {
self.page_setup_scale = s;
Task::none()
}
Message::PageSetupCommit => {
let i = self.active_tab;
let layout_name = self.tabs[i].scene.current_layout.clone();
if layout_name != "Model" {
let w: f64 = self.page_setup_w.parse::<f64>().unwrap_or(297.0).max(1.0);
let h: f64 = self.page_setup_h.parse::<f64>().unwrap_or(210.0).max(1.0);
let plot_area = self.page_setup_plot_area.clone();
let center = self.page_setup_center;
let offset_x = self.page_setup_offset_x.parse::<f64>().unwrap_or(0.0);
let offset_y = self.page_setup_offset_y.parse::<f64>().unwrap_or(0.0);
let rotation: i16 = self.page_setup_rotation.parse().unwrap_or(0);
let scale_str = self.page_setup_scale.clone();
// Update the Layout object's limits.
for obj in self.tabs[i].scene.document.objects.values_mut() {
if let acadrust::objects::ObjectType::Layout(l) = obj {
if l.name == layout_name {
l.min_limits = (0.0, 0.0);
l.max_limits = (w, h);
l.min_extents = (0.0, 0.0, 0.0);
l.max_extents = (w, h, 0.0);
break;
}
}
}
// Find or create the PlotSettings object for this layout.
use acadrust::objects::{
ObjectType, PlotPaperUnits, PlotRotation, PlotSettings, PlotType,
};
let plot_handle =
self.tabs[i]
.scene
.document
.objects
.iter()
.find_map(|(h, obj)| {
if let ObjectType::PlotSettings(ps) = obj {
if ps.page_name == layout_name {
Some(*h)
} else {
None
}
} else {
None
}
});
let ps_entry = if let Some(h) = plot_handle {
self.tabs[i].scene.document.objects.get_mut(&h)
} else {
// Create a new PlotSettings object and insert it.
let mut ps = PlotSettings::new(layout_name.clone());
ps.handle =
self.tabs[i].scene.document.allocate_handle();
let h = ps.handle;
self.tabs[i]
.scene
.document
.objects
.insert(h, ObjectType::PlotSettings(ps));
self.tabs[i].scene.document.objects.get_mut(&h)
};
if let Some(ObjectType::PlotSettings(ps)) = ps_entry {
ps.paper_width = w;
ps.paper_height = h;
ps.paper_units = PlotPaperUnits::Millimeters;
ps.plot_type = if plot_area == "Extents" {
PlotType::Extents
} else {
PlotType::Layout
};
ps.flags.plot_centered = center;
ps.origin_x = offset_x;
ps.origin_y = offset_y;
ps.rotation = match rotation {
90 => PlotRotation::Degrees90,
180 => PlotRotation::Degrees180,
270 => PlotRotation::Degrees270,
_ => PlotRotation::None,
};
// Apply plot scale.
use acadrust::objects::ScaledType;
let (num, den) = parse_plot_scale(&scale_str);
if scale_str == "Fit" {
ps.set_scale_to_fit();
} else {
ps.scale_type = ScaledType::CustomScale;
ps.scale_numerator = num;
ps.scale_denominator = den;
}
}
self.tabs[i].dirty = true;
self.command_line.push_info(&format!(
"Page setup: {w:.1}×{h:.1} mm area={plot_area} \
center={center} rot={rotation}°"
));
}
if let Some(id) = self.page_setup_window.take() {
return window::close(id);
}
Task::none()
}
// ── Plot / Export ─────────────────────────────────────────────
Message::PlotExport => {
let i = self.active_tab;
let stem = self.tabs[i]
.current_path
.as_deref()
.and_then(|p: &std::path::Path| p.file_stem())
.map(|s: &std::ffi::OsStr| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "drawing".into());
Task::perform(
crate::io::pdf_export::pick_pdf_path_owned(stem),
Message::PlotExportPath,
)
}
Message::PlotExportPath(None) => Task::none(),
Message::PlotExportPath(Some(path)) => {
let i = self.active_tab;
let scene = &self.tabs[i].scene;
let layout_name = scene.current_layout.clone();
let wires = scene.entity_wires();
let hatches = scene.paper_canvas_hatches();
let wipeouts = scene.paper_canvas_wipeouts();
// Read PlotSettings for current layout (if available).
use acadrust::objects::{ObjectType, PlotType};
let ps_snap = scene.document.objects.values().find_map(|obj| {
if let ObjectType::PlotSettings(ps) = obj {
if ps.page_name == layout_name {
Some(ps.clone())
} else {
None
}
} else {
None
}
});
// Determine paper size and drawing offset.
let (paper_w, paper_h, mut draw_ox, mut draw_oy, rotation_deg) =
if let Some(((x0, y0), (x1, y1))) = scene.paper_limits() {
let (pw, ph) = (x1 - x0, y1 - y0);
// If PlotSettings says Extents, use model space extents instead.
let use_extents = ps_snap
.as_ref()
.map(|ps| matches!(ps.plot_type, PlotType::Extents))
.unwrap_or(false);
let (ox, oy) = if use_extents {
if let Some((mn, _mx)) = scene.model_space_extents() {
(-mn.x as f64, -mn.y as f64)
} else {
(-x0, -y0)
}
} else {
(-x0, -y0)
};
let rot = ps_snap
.as_ref()
.map(|ps| ps.rotation.to_degrees() as i32)
.unwrap_or(0);
(pw, ph, ox, oy, rot)
} else {
// Model space: fit with 5% margin.
let margin = 1.05_f64;
if let Some((mn, mx)) = scene.model_space_extents() {
let w = ((mx.x - mn.x) as f64 * margin).max(1.0);
let h = ((mx.y - mn.y) as f64 * margin).max(1.0);
let pad_x = (w - (mx.x - mn.x) as f64) * 0.5;
let pad_y = (h - (mx.y - mn.y) as f64) * 0.5;
(w, h, -(mn.x as f64) + pad_x, -(mn.y as f64) + pad_y, 0)
} else {
(297.0, 210.0, 0.0, 0.0, 0)
}
};
// Apply PlotSettings offset and centering.
if let Some(ref ps) = ps_snap {
if ps.flags.plot_centered {
// Centering: compute wire extents and re-centre.
let all_x: Vec<f32> = wires
.iter()
.flat_map(|w| w.points.iter().map(|p| p[0]))
.filter(|v| !v.is_nan())
.collect();
let all_y: Vec<f32> = wires
.iter()
.flat_map(|w| w.points.iter().map(|p| p[1]))
.filter(|v| !v.is_nan())
.collect();
if let (Some(&min_x), Some(&max_x), Some(&min_y), Some(&max_y)) = (
all_x.iter().copied().reduce(f32::min).as_ref(),
all_x.iter().copied().reduce(f32::max).as_ref(),
all_y.iter().copied().reduce(f32::min).as_ref(),
all_y.iter().copied().reduce(f32::max).as_ref(),
) {
let cx = (min_x + max_x) as f64 / 2.0;
let cy = (min_y + max_y) as f64 / 2.0;
draw_ox += paper_w / 2.0 - cx;
draw_oy += paper_h / 2.0 - cy;
}
} else {
draw_ox += ps.origin_x;
draw_oy += ps.origin_y;
}
}
// For rotation: swap paper dimensions and note angle for export.
let (eff_w, eff_h) = match rotation_deg {
90 | 270 => (paper_h, paper_w),
_ => (paper_w, paper_h),
};
match crate::io::pdf_export::export_pdf(
&wires,
hatches.as_slice(),
wipeouts.as_slice(),
eff_w,
eff_h,
draw_ox as f32,
draw_oy as f32,
rotation_deg,
&path,
self.active_plot_style.as_ref(),
) {
Ok(()) => self.command_line.push_info(&format!(
"Exported: {}",
path.file_name().unwrap_or_default().to_string_lossy()
)),
Err(e) => self.command_line.push_error(&format!("Export failed: {e}")),
}
Task::none()
}
// ── Print to system printer ───────────────────────────────────────
Message::PrintToPrinter => {
let i = self.active_tab;
let scene = &self.tabs[i].scene;
let layout_name = scene.current_layout.clone();
let wires = scene.entity_wires();
let hatches: Vec<_> = scene.paper_canvas_hatches().as_ref().clone();
let wipeouts: Vec<_> = scene.paper_canvas_wipeouts().as_ref().clone();
use acadrust::objects::{ObjectType, PlotType};
let ps_snap = scene.document.objects.values().find_map(|obj| {
if let ObjectType::PlotSettings(ps) = obj {
if ps.page_name == layout_name {
Some(ps.clone())
} else {
None
}
} else {
None
}
});
let (paper_w, paper_h, draw_ox, draw_oy, rotation_deg) =
if let Some(((x0, y0), (x1, y1))) = scene.paper_limits() {
let (pw, ph) = (x1 - x0, y1 - y0);
let use_extents = ps_snap
.as_ref()
.map(|ps| matches!(ps.plot_type, PlotType::Extents))
.unwrap_or(false);
let (ox, oy) = if use_extents {
if let Some((mn, _mx)) = scene.model_space_extents() {
(-mn.x as f64, -mn.y as f64)
} else {
(-x0, -y0)
}
} else {
(-x0, -y0)
};
let rot = ps_snap
.as_ref()
.map(|ps| ps.rotation.to_degrees() as i32)
.unwrap_or(0);
(pw, ph, ox, oy, rot)
} else {
if let Some((mn, mx)) = scene.model_space_extents() {
let margin = 1.05_f64;
let w = ((mx.x - mn.x) as f64 * margin).max(1.0);
let h = ((mx.y - mn.y) as f64 * margin).max(1.0);
let pad_x = (w - (mx.x - mn.x) as f64) * 0.5;
let pad_y = (h - (mx.y - mn.y) as f64) * 0.5;
(w, h, -(mn.x as f64) + pad_x, -(mn.y as f64) + pad_y, 0)
} else {
(297.0, 210.0, 0.0, 0.0, 0)
}
};
let (eff_w, eff_h) = match rotation_deg {
90 | 270 => (paper_h, paper_w),
_ => (paper_w, paper_h),
};
let plot_style = self.active_plot_style.clone();
self.command_line.push_info("Sending to system printer…");
Task::perform(
async move {
crate::io::print_to_printer::print_wires(
wires,
hatches,
wipeouts,
eff_w,
eff_h,
draw_ox as f32,
draw_oy as f32,
rotation_deg,
plot_style,
)
.await
},
Message::PrintResult,
)
}
Message::PrintResult(Ok(printer)) => {
self.command_line
.push_info(&format!("Sent to printer: {printer}"));
Task::none()
}
Message::PrintResult(Err(e)) => {
self.command_line.push_error(&format!("Print failed: {e}"));
Task::none()
}
// ── Plot Style Table ──────────────────────────────────────────────
Message::PlotStyleLoad => {
Task::perform(crate::io::pick_plot_style(), Message::PlotStyleLoaded)
}
Message::PlotStyleLoaded(Some(table)) => {
self.command_line.push_output(&format!(
"Plot style '{}' loaded ({} color entries).",
table.name,
table
.aci_entries
.iter()
.filter(|e| e.color.is_some())
.count()
));
self.active_plot_style = Some(table);
Task::none()
}
Message::PlotStyleLoaded(None) => Task::none(),
Message::PlotStyleClear => {
self.active_plot_style = None;
self.command_line.push_output("Plot style table cleared.");
Task::none()
}
// ── Plot Style Panel ──────────────────────────────────────────────
Message::PlotStylePanelOpen => {
// Initialise edit buffers for ACI 1.
self.plotstyle_panel_aci = 1;
let entry = self
.active_plot_style
.as_ref()
.and_then(|t| t.aci_entries.get(1));
self.ps_color_buf = entry
.and_then(|e| {
e.color
.map(|[r, g, b]| format!("#{:02X}{:02X}{:02X}", r, g, b))
})
.unwrap_or_default();
self.ps_lineweight_buf = entry
.map(|e| e.lineweight.to_string())
.unwrap_or("255".into());
self.ps_screening_buf = entry
.map(|e| e.screening.to_string())
.unwrap_or("100".into());
if let Some(id) = self.plotstyle_window {
return window::gain_focus(id);
}
let (id, task) = window::open(window::Settings {
size: iced::Size::new(780.0, 540.0),
resizable: true,
level: window::Level::AlwaysOnTop,
..Default::default()
});
self.plotstyle_window = Some(id);
task.map(|_| Message::Noop)
}
Message::PlotStylePanelClose => {
if let Some(id) = self.plotstyle_window.take() {
window::close(id)
} else {
Task::none()
}
}
Message::PlotStylePanelSelectAci(aci) => {
self.plotstyle_panel_aci = aci;
let entry = self
.active_plot_style
.as_ref()
.and_then(|t| t.aci_entries.get(aci as usize));
self.ps_color_buf = entry
.and_then(|e| {
e.color
.map(|[r, g, b]| format!("#{:02X}{:02X}{:02X}", r, g, b))
})
.unwrap_or_default();
self.ps_lineweight_buf = entry
.map(|e| e.lineweight.to_string())
.unwrap_or("255".into());
self.ps_screening_buf = entry
.map(|e| e.screening.to_string())
.unwrap_or("100".into());
Task::none()
}
Message::PlotStylePanelColorBuf(s) => {
self.ps_color_buf = s;
Task::none()
}
Message::PlotStylePanelLwBuf(s) => {
self.ps_lineweight_buf = s;
Task::none()
}
Message::PlotStylePanelScreenBuf(s) => {
self.ps_screening_buf = s;
Task::none()
}
Message::PlotStylePanelApply => {
let aci = self.plotstyle_panel_aci as usize;
if let Some(table) = self.active_plot_style.as_mut() {
if let Some(entry) = table.aci_entries.get_mut(aci) {
// Parse color.
let color_str = self.ps_color_buf.trim();
if color_str.is_empty() {
entry.color = None;
} else if color_str.starts_with('#') && color_str.len() == 7 {
let r = u8::from_str_radix(&color_str[1..3], 16).unwrap_or(0);
let g = u8::from_str_radix(&color_str[3..5], 16).unwrap_or(0);
let b = u8::from_str_radix(&color_str[5..7], 16).unwrap_or(0);
entry.color = Some([r, g, b]);
}
if let Ok(lw) = self.ps_lineweight_buf.trim().parse::<u8>() {
entry.lineweight = lw;
}
if let Ok(sc) = self.ps_screening_buf.trim().parse::<u8>() {
entry.screening = sc.min(100);
}
self.command_line
.push_output(&format!("Plot style ACI {aci} updated."));
}
} else {
// No table loaded: create an identity table and apply.
let mut table = crate::io::plot_style::PlotStyleTable::identity("Custom.ctb");
if let Some(entry) = table.aci_entries.get_mut(aci) {
let color_str = self.ps_color_buf.trim();
if color_str.starts_with('#') && color_str.len() == 7 {
let r = u8::from_str_radix(&color_str[1..3], 16).unwrap_or(0);
let g = u8::from_str_radix(&color_str[3..5], 16).unwrap_or(0);
let b = u8::from_str_radix(&color_str[5..7], 16).unwrap_or(0);
entry.color = Some([r, g, b]);
}
if let Ok(lw) = self.ps_lineweight_buf.trim().parse::<u8>() {
entry.lineweight = lw;
}
if let Ok(sc) = self.ps_screening_buf.trim().parse::<u8>() {
entry.screening = sc.min(100);
}
}
self.active_plot_style = Some(table);
self.command_line
.push_output(&format!("Created new CTB table, ACI {aci} updated."));
}
Task::none()
}
Message::PlotStylePanelSave => {
if self.active_plot_style.is_none() {
self.command_line
.push_error("No plot style table loaded. Load or create one first.");
return Task::none();
}
let default_name = self
.active_plot_style
.as_ref()
.map(|t| t.name.clone())
.unwrap_or("export.ctb".into());
Task::perform(
async move {
rfd::AsyncFileDialog::new()
.set_title("Save Plot Style Table")
.set_file_name(&default_name)
.add_filter("Plot Style Files", &["ctb", "stb", "CTB", "STB"])
.add_filter("All Files", &["*"])
.save_file()
.await
.map(|h| h.path().to_path_buf())
},
Message::PlotStylePanelSavePath,
)
}
Message::PlotStylePanelSavePath(Some(path)) => {
if let Some(table) = &self.active_plot_style {
match table.save(&path) {
Ok(()) => self.command_line.push_output(&format!(
"Plot style table saved to \"{}\".",
path.display()
)),
Err(e) => self.command_line.push_error(&format!("Save error: {e}")),
}
}
Task::none()
}
Message::PlotStylePanelSavePath(None) => Task::none(),
// ── TextStyle Font Browser ────────────────────────────────────────
Message::TextStyleDialogOpen => {
let i = self.active_tab;
let cur = self.tabs[i]
.scene
.document
.header
.current_text_style_name
.clone();
let exists = self.tabs[i].scene.document.text_styles.get(&cur).is_some();
self.textstyle_selected = if exists {
cur
} else {
self.tabs[i]
.scene
.document
.text_styles
.iter()
.next()
.map(|s| s.name.clone())
.unwrap_or_else(|| "Standard".to_string())
};
self.load_textstyle_bufs(i);
if let Some(id) = self.textstyle_window {
return window::gain_focus(id);
}
let (id, task) = window::open(window::Settings {
size: iced::Size::new(620.0, 460.0),
resizable: true,
level: window::Level::AlwaysOnTop,
..Default::default()
});
self.textstyle_window = Some(id);
task.map(|_| Message::Noop)
}
Message::TextStyleDialogClose => {
if let Some(id) = self.textstyle_window.take() {
window::close(id)
} else {
Task::none()
}
}
Message::TextStyleDialogSelect(name) => {
let i = self.active_tab;
self.textstyle_selected = name;
self.load_textstyle_bufs(i);
Task::none()
}
Message::TextStyleDialogSetCurrent => {
let i = self.active_tab;
let name = self.textstyle_selected.clone();
if self.tabs[i].scene.document.text_styles.get(&name).is_some() {
self.push_undo_snapshot(i, "STYLE SET");
self.tabs[i].scene.document.header.current_text_style_name = name.clone();
self.tabs[i].dirty = true;
self.command_line
.push_output(&format!("Current text style: {}", name));
}
Task::none()
}
Message::TextStyleDialogNew => {
let i = self.active_tab;
let doc = &self.tabs[i].scene.document;
let mut n = 1u32;
let new_name = loop {
let candidate = format!("Style{}", n);
if !doc.text_styles.contains(&candidate) {
break candidate;
}
n += 1;
};
self.push_undo_snapshot(i, "STYLE NEW");
let style = acadrust::tables::TextStyle::new(&new_name);
let _ = self.tabs[i].scene.document.text_styles.add(style);
self.textstyle_selected = new_name.clone();
self.textstyle_font = String::new();
self.textstyle_width = "1.0".to_string();
self.textstyle_oblique = "0.0".to_string();
self.tabs[i].dirty = true;
Task::none()
}
Message::TextStyleDialogDelete => {
let i = self.active_tab;
let name = self.textstyle_selected.clone();
if name.eq_ignore_ascii_case("Standard") {
self.command_line
.push_error("Cannot delete the Standard text style.");
return Task::none();
}
self.push_undo_snapshot(i, "STYLE DEL");
self.tabs[i].scene.document.text_styles.remove(&name);
self.textstyle_selected = self.tabs[i]
.scene
.document
.text_styles
.iter()
.next()
.map(|s| s.name.clone())
.unwrap_or_else(|| "Standard".to_string());
self.load_textstyle_bufs(i);
self.tabs[i].dirty = true;
Task::none()
}
Message::TextStyleEdit { field, value } => {
match field {
"font" => self.textstyle_font = value,
"width" => self.textstyle_width = value,
"oblique" => self.textstyle_oblique = value,
"height" => self.textstyle_height = value,
"bigfont" => self.textstyle_bigfont = value,
"ttf" => self.textstyle_ttf = value,
_ => {}
}
Task::none()
}
Message::TextStyleToggle(field) => {
let i = self.active_tab;
let name = self.textstyle_selected.clone();
if self.tabs[i].scene.document.text_styles.get(&name).is_some() {
self.push_undo_snapshot(i, "STYLE EDIT");
if let Some(s) = self.tabs[i].scene.document.text_styles.get_mut(&name) {
match field {
"backward" => s.flags.backward = !s.flags.backward,
"upside_down" => s.flags.upside_down = !s.flags.upside_down,
"annotative" => s.annotative = !s.annotative,
_ => {}
}
}
self.tabs[i].scene.bump_geometry();
self.tabs[i].dirty = true;
}
Task::none()
}
Message::TextStyleApply => {
let i = self.active_tab;
let name = self.textstyle_selected.clone();
if self.tabs[i].scene.document.text_styles.get(&name).is_some() {
self.push_undo_snapshot(i, "STYLE EDIT");
let font = self.textstyle_font.clone();
let width_str = self.textstyle_width.clone();
let oblique_str = self.textstyle_oblique.clone();
let height_str = self.textstyle_height.clone();
let bigfont = self.textstyle_bigfont.clone();
let ttf = self.textstyle_ttf.clone();
if let Some(s) = self.tabs[i].scene.document.text_styles.get_mut(&name) {
s.font_file = font;
s.big_font_file = bigfont;
s.true_type_font = ttf;
if let Ok(w) = width_str.trim().parse::<f64>() {
s.width_factor = w;
}
if let Ok(a) = oblique_str.trim().parse::<f64>() {
s.oblique_angle = a.to_radians();
}
if let Ok(h) = height_str.trim().parse::<f64>() {
s.height = h.max(0.0);
}
}
self.tabs[i].scene.bump_geometry();
self.tabs[i].dirty = true;
}
Task::none()
}
Message::TextStyleFontPick(font_file) => {
let i = self.active_tab;
self.textstyle_font = font_file.clone();
let name = self.textstyle_selected.clone();
if self.tabs[i].scene.document.text_styles.get(&name).is_some() {
self.push_undo_snapshot(i, "STYLE FONT");
if let Some(s) = self.tabs[i].scene.document.text_styles.get_mut(&name) {
s.font_file = font_file;
}
self.tabs[i].dirty = true;
}
Task::none()
}
// ── TableStyle Dialog ─────────────────────────────────────────────
Message::TableStyleDialogOpen => {
use acadrust::objects::ObjectType;
let i = self.active_tab;
self.tablestyle_selected = self.tabs[i]
.scene
.document
.objects
.values()
.find_map(|o| {
if let ObjectType::TableStyle(s) = o {
Some(s.name.clone())
} else {
None
}
})
.unwrap_or_else(|| "Standard".to_string());
self.load_tablestyle_bufs(i);
if let Some(id) = self.tablestyle_window {
return window::gain_focus(id);
}
let (id, task) = window::open(window::Settings {
size: iced::Size::new(620.0, 420.0),
resizable: true,
level: window::Level::AlwaysOnTop,
..Default::default()
});
self.tablestyle_window = Some(id);
task.map(|_| Message::Noop)
}
Message::TableStyleDialogClose => {
if let Some(id) = self.tablestyle_window.take() {
window::close(id)
} else {
Task::none()
}
}
Message::TableStyleDialogSelect(name) => {
self.tablestyle_selected = name;
let i = self.active_tab;
self.load_tablestyle_bufs(i);
Task::none()
}
Message::TableStyleEdit { field, value } => {
match field {
"hmargin" => self.ts_hmargin = value,
"vmargin" => self.ts_vmargin = value,
"description" => self.ts_description = value,
_ => {}
}
Task::none()
}
Message::TableStyleApply => {
use acadrust::objects::ObjectType;
let i = self.active_tab;
let name = self.tablestyle_selected.clone();
let h: Option<f64> = self.ts_hmargin.trim().parse().ok();
let v: Option<f64> = self.ts_vmargin.trim().parse().ok();
let desc = self.ts_description.clone();
self.push_undo_snapshot(i, "TABLESTYLE EDIT");
for obj in self.tabs[i].scene.document.objects.values_mut() {
if let ObjectType::TableStyle(s) = obj {
if s.name == name {
if let Some(h) = h {
s.horizontal_margin = h;
}
if let Some(v) = v {
s.vertical_margin = v;
}
s.description = desc.clone();
}
}
}
self.tabs[i].dirty = true;
Task::none()
}
Message::TableStyleSetFlow(value) => {
use acadrust::objects::TableFlowDirection;
let i = self.active_tab;
if let Some(s) = self.tablestyle_mut(i) {
s.flow_direction = match value.as_str() {
"Up" => TableFlowDirection::Up,
_ => TableFlowDirection::Down,
};
self.push_undo_snapshot(i, "TABLESTYLE EDIT");
self.tabs[i].dirty = true;
}
Task::none()
}
Message::TableStyleCellEdit { row, field, value } => {
let r = row as usize;
if r < 3 {
match field {
"textstyle" => self.ts_cell_textstyle[r] = value,
"height" => self.ts_cell_height[r] = value,
"textcolor" => self.ts_cell_textcolor[r] = value,
"fillcolor" => self.ts_cell_fillcolor[r] = value,
"datatype" => self.ts_cell_datatype[r] = value,
"unittype" => self.ts_cell_unittype[r] = value,
"format" => self.ts_cell_format[r] = value,
_ => {}
}
}
Task::none()
}
Message::TableStyleBorderEdit {
cell,
border,
field,
value,
} => {
let (c, b) = (cell as usize, border as usize);
if c < 3 && b < 6 {
match field {
"lw" => self.ts_border_lw[c][b] = value,
"color" => self.ts_border_color[c][b] = value,
"spacing" => self.ts_border_spacing[c][b] = value,
_ => {}
}
}
Task::none()
}
Message::TableStyleBorderSetType {
cell,
border,
value,
} => {
use acadrust::objects::TableBorderType;
let i = self.active_tab;
if let Some(s) = self.tablestyle_mut(i) {
if let Some(bd) = Self::ts_cell_of(s, cell).and_then(|c| Self::ts_border_of(c, border))
{
bd.border_type = match value.as_str() {
"Double" => TableBorderType::Double,
_ => TableBorderType::Single,
};
}
self.push_undo_snapshot(i, "TABLESTYLE EDIT");
self.tabs[i].dirty = true;
self.tabs[i].scene.bump_geometry();
}
Task::none()
}
Message::TableStyleBorderToggleInvisible { cell, border } => {
let i = self.active_tab;
if let Some(s) = self.tablestyle_mut(i) {
if let Some(bd) = Self::ts_cell_of(s, cell).and_then(|c| Self::ts_border_of(c, border))
{
bd.is_invisible = !bd.is_invisible;
}
self.push_undo_snapshot(i, "TABLESTYLE EDIT");
self.tabs[i].dirty = true;
self.tabs[i].scene.bump_geometry();
}
Task::none()
}
Message::TableStyleCellToggleFill(row) => {
let i = self.active_tab;
if let Some(s) = self.tablestyle_mut(i) {
if let Some(c) = Self::ts_cell_of(s, row) {
c.fill_enabled = !c.fill_enabled;
}
self.push_undo_snapshot(i, "TABLESTYLE EDIT");
self.tabs[i].dirty = true;
self.tabs[i].scene.bump_geometry();
}
Task::none()
}
Message::TableStyleCellSetAlign { row, value } => {
use acadrust::objects::CellAlignment;
let i = self.active_tab;
if let Some(s) = self.tablestyle_mut(i) {
if let Some(c) = Self::ts_cell_of(s, row) {
c.alignment = match value.as_str() {
"TopLeft" => CellAlignment::TopLeft,
"TopCenter" => CellAlignment::TopCenter,
"TopRight" => CellAlignment::TopRight,
"MiddleLeft" => CellAlignment::MiddleLeft,
"MiddleRight" => CellAlignment::MiddleRight,
"BottomLeft" => CellAlignment::BottomLeft,
"BottomCenter" => CellAlignment::BottomCenter,
"BottomRight" => CellAlignment::BottomRight,
_ => CellAlignment::MiddleCenter,
};
}
self.push_undo_snapshot(i, "TABLESTYLE EDIT");
self.tabs[i].dirty = true;
self.tabs[i].scene.bump_geometry();
}
Task::none()
}
Message::TableStyleCellApply(row) => {
let i = self.active_tab;
let r = row as usize;
if r >= 3 {
return Task::none();
}
let ts = self.ts_cell_textstyle[r].trim().to_string();
let height: Option<f64> = self.ts_cell_height[r].trim().parse().ok();
let tc: Option<i16> = self.ts_cell_textcolor[r].trim().parse().ok();
let fc: Option<i16> = self.ts_cell_fillcolor[r].trim().parse().ok();
let dtype: Option<i32> = self.ts_cell_datatype[r].trim().parse().ok();
let utype: Option<i32> = self.ts_cell_unittype[r].trim().parse().ok();
let fmt = self.ts_cell_format[r].clone();
// Per-border numeric edits for this cell.
let border_vals: [(Option<i16>, Option<i16>, Option<f64>); 6] =
std::array::from_fn(|b| {
(
self.ts_border_lw[r][b].trim().parse().ok(),
self.ts_border_color[r][b].trim().parse().ok(),
self.ts_border_spacing[r][b].trim().parse().ok(),
)
});
if let Some(c) = self.tablestyle_mut(i).and_then(|s| Self::ts_cell_of(s, row)) {
if !ts.is_empty() {
c.text_style_name = ts;
}
if let Some(h) = height {
c.text_height = h;
}
if let Some(v) = tc {
c.text_color = acadrust::types::Color::from_index(v);
}
if let Some(v) = fc {
c.fill_color = acadrust::types::Color::from_index(v);
}
if let Some(v) = dtype {
c.data_type = v;
}
if let Some(v) = utype {
c.unit_type = v;
}
c.format_string = fmt;
for (b, (lw, color, spacing)) in border_vals.into_iter().enumerate() {
if let Some(bd) = Self::ts_border_of(c, b as u8) {
if let Some(v) = lw {
bd.line_weight = acadrust::types::LineWeight::from_value(v);
}
if let Some(v) = color {
bd.color = acadrust::types::Color::from_index(v);
}
if let Some(v) = spacing {
bd.double_line_spacing = v;
}
}
}
self.push_undo_snapshot(i, "TABLESTYLE EDIT");
self.tabs[i].dirty = true;
self.tabs[i].scene.bump_geometry();
}
Task::none()
}
Message::TableStyleToggle(field) => {
use acadrust::objects::ObjectType;
let i = self.active_tab;
let name = self.tablestyle_selected.clone();
self.push_undo_snapshot(i, "TABLESTYLE EDIT");
for obj in self.tabs[i].scene.document.objects.values_mut() {
if let ObjectType::TableStyle(s) = obj {
if s.name == name {
match field {
"title_sup" => s.title_suppressed = !s.title_suppressed,
"header_sup" => s.header_suppressed = !s.header_suppressed,
_ => {}
}
}
}
}
self.tabs[i].dirty = true;
Task::none()
}
Message::TableStyleToggleAnnotative => {
use acadrust::objects::ObjectType;
let i = self.active_tab;
let name = self.tablestyle_selected.clone();
self.push_undo_snapshot(i, "TABLESTYLE EDIT");
for obj in self.tabs[i].scene.document.objects.values_mut() {
if let ObjectType::TableStyle(s) = obj {
if s.name == name {
s.annotative = !s.annotative;
}
}
}
self.tabs[i].dirty = true;
Task::none()
}
Message::TableStyleDialogNew => {
use acadrust::objects::ObjectType;
let i = self.active_tab;
let doc = &self.tabs[i].scene.document;
let mut n = 1u32;
let new_name = loop {
let candidate = format!("TS{}", n);
let taken = doc.objects.values().any(|o| {
matches!(o, ObjectType::TableStyle(s) if s.name.eq_ignore_ascii_case(&candidate))
});
if !taken {
break candidate;
}
n += 1;
};
self.push_undo_snapshot(i, "TABLESTYLE NEW");
let mut style = acadrust::objects::TableStyle::standard();
style.name = new_name.clone();
let nh = self.tabs[i].scene.document.allocate_handle();
style.handle = nh;
self.tabs[i]
.scene
.document
.objects
.insert(nh, ObjectType::TableStyle(style));
self.tablestyle_selected = new_name;
self.tabs[i].dirty = true;
Task::none()
}
Message::TableStyleDialogDelete => {
use acadrust::objects::ObjectType;
let i = self.active_tab;
let name = self.tablestyle_selected.clone();
if name.eq_ignore_ascii_case("Standard") {
self.command_line
.push_error("Cannot delete the Standard style.");
return Task::none();
}
let handle = self.tabs[i]
.scene
.document
.objects
.iter()
.find_map(|(&h, o)| {
if let ObjectType::TableStyle(s) = o {
if s.name == name {
Some(h)
} else {
None
}
} else {
None
}
});
if let Some(h) = handle {
self.push_undo_snapshot(i, "TABLESTYLE DEL");
self.tabs[i].scene.document.objects.remove(&h);
self.tablestyle_selected = self.tabs[i]
.scene
.document
.objects
.values()
.find_map(|o| {
if let ObjectType::TableStyle(s) = o {
Some(s.name.clone())
} else {
None
}
})
.unwrap_or_else(|| "Standard".to_string());
self.tabs[i].dirty = true;
}
Task::none()
}
// ── MLineStyle Dialog ─────────────────────────────────────────────
Message::MlStyleDialogOpen => {
use acadrust::objects::ObjectType;
let i = self.active_tab;
let cur = self.tabs[i].scene.document.header.multiline_style.clone();
let exists = self.tabs[i]
.scene
.document
.objects
.values()
.any(|o| matches!(o, ObjectType::MLineStyle(s) if s.name == cur));
self.mlstyle_selected = if exists {
cur
} else {
self.tabs[i]
.scene
.document
.objects
.values()
.find_map(|o| {
if let ObjectType::MLineStyle(s) = o {
Some(s.name.clone())
} else {
None
}
})
.unwrap_or_else(|| "Standard".to_string())
};
if let Some(id) = self.mlstyle_window {
return window::gain_focus(id);
}
let (id, task) = window::open(window::Settings {
size: iced::Size::new(620.0, 420.0),
resizable: true,
level: window::Level::AlwaysOnTop,
..Default::default()
});
self.mlstyle_window = Some(id);
task.map(|_| Message::Noop)
}
Message::MlStyleDialogClose => {
if let Some(id) = self.mlstyle_window.take() {
window::close(id)
} else {
Task::none()
}
}
Message::MlStyleDialogSelect(name) => {
self.mlstyle_selected = name;
Task::none()
}
Message::MlStyleDialogSetCurrent => {
use acadrust::objects::ObjectType;
let i = self.active_tab;
let name = self.mlstyle_selected.clone();
let exists = self.tabs[i]
.scene
.document
.objects
.values()
.any(|o| matches!(o, ObjectType::MLineStyle(s) if s.name == name));
if exists {
self.push_undo_snapshot(i, "MLSTYLE SET");
self.tabs[i].scene.document.header.multiline_style = name.clone();
self.tabs[i].dirty = true;
self.command_line
.push_output(&format!("Current multiline style: {}", name));
}
Task::none()
}
Message::MlStyleDialogNew => {
use acadrust::objects::ObjectType;
let i = self.active_tab;
// Generate a unique name.
let doc = &self.tabs[i].scene.document;
let mut n = 1u32;
let base = "MLS";
let new_name = loop {
let candidate = format!("{}{}", base, n);
let taken = doc.objects.values().any(|o| {
matches!(o, ObjectType::MLineStyle(s) if s.name.eq_ignore_ascii_case(&candidate))
});
if !taken {
break candidate;
}
n += 1;
};
self.push_undo_snapshot(i, "MLSTYLE NEW");
let mut style = acadrust::objects::MLineStyle::standard();
style.name = new_name.clone();
let nh = self.tabs[i].scene.document.allocate_handle();
style.handle = nh;
self.tabs[i]
.scene
.document
.objects
.insert(nh, ObjectType::MLineStyle(style));
self.mlstyle_selected = new_name;
self.tabs[i].dirty = true;
Task::none()
}
Message::MlStyleDialogDelete => {
use acadrust::objects::ObjectType;
let i = self.active_tab;
let name = self.mlstyle_selected.clone();
if name.eq_ignore_ascii_case("Standard") {
self.command_line
.push_error("Cannot delete the Standard style.");
return Task::none();
}
let handle = self.tabs[i]
.scene
.document
.objects
.iter()
.find_map(|(&h, o)| {
if let ObjectType::MLineStyle(s) = o {
if s.name == name {
Some(h)
} else {
None
}
} else {
None
}
});
if let Some(h) = handle {
self.push_undo_snapshot(i, "MLSTYLE DEL");
self.tabs[i].scene.document.objects.remove(&h);
// Select first remaining style.
self.mlstyle_selected = self.tabs[i]
.scene
.document
.objects
.values()
.find_map(|o| {
if let ObjectType::MLineStyle(s) = o {
Some(s.name.clone())
} else {
None
}
})
.unwrap_or_else(|| "Standard".to_string());
self.tabs[i].dirty = true;
}
Task::none()
}
// ── MLeaderStyle Dialog ───────────────────────────────────────────
Message::MLeaderStyleDialogOpen => {
use acadrust::objects::ObjectType;
let i = self.active_tab;
let cur = self.tabs[i].active_mleader_style.clone();
let exists = self.tabs[i]
.scene
.document
.objects
.values()
.any(|o| matches!(o, ObjectType::MultiLeaderStyle(s) if s.name == cur));
self.mleaderstyle_selected = if exists {
cur
} else {
self.tabs[i]
.scene
.document
.objects
.values()
.find_map(|o| {
if let ObjectType::MultiLeaderStyle(s) = o {
Some(s.name.clone())
} else {
None
}
})
.unwrap_or_else(|| "Standard".to_string())
};
self.load_mleaderstyle_bufs(i);
if let Some(id) = self.mleaderstyle_window {
return window::gain_focus(id);
}
let (id, task) = window::open(window::Settings {
size: iced::Size::new(560.0, 560.0),
resizable: true,
level: window::Level::AlwaysOnTop,
..Default::default()
});
self.mleaderstyle_window = Some(id);
task.map(|_| Message::Noop)
}
Message::MLeaderStyleDialogClose => {
if let Some(id) = self.mleaderstyle_window.take() {
window::close(id)
} else {
Task::none()
}
}
Message::MLeaderStyleDialogSelect(name) => {
self.mleaderstyle_selected = name;
let i = self.active_tab;
self.load_mleaderstyle_bufs(i);
Task::none()
}
Message::MLeaderStyleDialogSetCurrent => {
use acadrust::objects::ObjectType;
let i = self.active_tab;
let name = self.mleaderstyle_selected.clone();
let exists = self.tabs[i]
.scene
.document
.objects
.values()
.any(|o| matches!(o, ObjectType::MultiLeaderStyle(s) if s.name == name));
if exists {
self.tabs[i].active_mleader_style = name.clone();
self.ribbon.active_mleader_style = name.clone();
self.command_line
.push_output(&format!("Current multileader style: {}", name));
}
Task::none()
}
Message::MLeaderStyleDialogNew => {
use acadrust::objects::ObjectType;
let i = self.active_tab;
let doc = &self.tabs[i].scene.document;
let mut n = 1u32;
let new_name = loop {
let candidate = format!("MLeader{}", n);
let taken = doc.objects.values().any(|o| {
matches!(o, ObjectType::MultiLeaderStyle(s) if s.name.eq_ignore_ascii_case(&candidate))
});
if !taken {
break candidate;
}
n += 1;
};
self.push_undo_snapshot(i, "MLEADERSTYLE NEW");
let mut style = acadrust::objects::MultiLeaderStyle::new(&new_name);
let nh = self.tabs[i].scene.document.allocate_handle();
style.handle = nh;
self.tabs[i]
.scene
.document
.objects
.insert(nh, ObjectType::MultiLeaderStyle(style));
self.mleaderstyle_selected = new_name;
self.load_mleaderstyle_bufs(i);
self.tabs[i].dirty = true;
Task::none()
}
Message::MLeaderStyleDialogDelete => {
use acadrust::objects::ObjectType;
let i = self.active_tab;
let name = self.mleaderstyle_selected.clone();
if name.eq_ignore_ascii_case("Standard") {
self.command_line
.push_error("Cannot delete the Standard style.");
return Task::none();
}
let handle = self.tabs[i]
.scene
.document
.objects
.iter()
.find_map(|(&h, o)| match o {
ObjectType::MultiLeaderStyle(s) if s.name == name => Some(h),
_ => None,
});
if let Some(h) = handle {
self.push_undo_snapshot(i, "MLEADERSTYLE DEL");
self.tabs[i].scene.document.objects.remove(&h);
self.mleaderstyle_selected = self.tabs[i]
.scene
.document
.objects
.values()
.find_map(|o| {
if let ObjectType::MultiLeaderStyle(s) = o {
Some(s.name.clone())
} else {
None
}
})
.unwrap_or_else(|| "Standard".to_string());
self.load_mleaderstyle_bufs(i);
self.tabs[i].dirty = true;
}
Task::none()
}
Message::MLeaderStyleEdit { field, value } => {
match field {
"landing_distance" => self.mls_landing_distance = value,
"landing_gap" => self.mls_landing_gap = value,
"arrowhead_size" => self.mls_arrowhead_size = value,
"text_height" => self.mls_text_height = value,
"scale_factor" => self.mls_scale_factor = value,
"break_gap" => self.mls_break_gap = value,
"first_seg_angle" => self.mls_first_seg_angle = value,
"second_seg_angle" => self.mls_second_seg_angle = value,
"max_points" => self.mls_max_points = value,
"default_text" => self.mls_default_text = value,
"line_color" => self.mls_line_color = value,
"text_color" => self.mls_text_color = value,
"description" => self.mls_description = value,
"line_weight" => self.mls_line_weight = value,
"align_space" => self.mls_align_space = value,
"block_color" => self.mls_block_color = value,
"block_rotation" => self.mls_block_rotation = value,
"block_scale_x" => self.mls_block_scale_x = value,
"block_scale_y" => self.mls_block_scale_y = value,
"block_scale_z" => self.mls_block_scale_z = value,
_ => {}
}
Task::none()
}
Message::MLeaderStyleToggle(field) => {
let i = self.active_tab;
if let Some(s) = self.mleaderstyle_mut(i) {
match field {
"enable_landing" => s.enable_landing = !s.enable_landing,
"enable_dogleg" => s.enable_dogleg = !s.enable_dogleg,
"text_frame" => s.text_frame = !s.text_frame,
"text_always_left" => s.text_always_left = !s.text_always_left,
"annotative" => s.is_annotative = !s.is_annotative,
"enable_block_scale" => s.enable_block_scale = !s.enable_block_scale,
"enable_block_rotation" => {
s.enable_block_rotation = !s.enable_block_rotation
}
_ => {}
}
self.push_undo_snapshot(i, "MLEADERSTYLE EDIT");
self.tabs[i].dirty = true;
self.tabs[i].scene.bump_geometry();
}
Task::none()
}
Message::MLeaderStyleSetEnum { field, value } => {
use acadrust::objects::{
BlockContentConnectionType, LeaderContentType, LeaderDrawOrderType,
MultiLeaderDrawOrderType, MultiLeaderPathType, TextAlignmentType,
TextAngleType, TextAttachmentDirectionType, TextAttachmentType,
};
// Parse a TextAttachmentType from its debug name.
fn parse_att(v: &str) -> TextAttachmentType {
match v {
"TopOfTopLine" => TextAttachmentType::TopOfTopLine,
"MiddleOfText" => TextAttachmentType::MiddleOfText,
"MiddleOfBottomLine" => TextAttachmentType::MiddleOfBottomLine,
"BottomOfBottomLine" => TextAttachmentType::BottomOfBottomLine,
"BottomLine" => TextAttachmentType::BottomLine,
"BottomOfTopLineUnderlineBottomLine" => {
TextAttachmentType::BottomOfTopLineUnderlineBottomLine
}
"BottomOfTopLineUnderlineTopLine" => {
TextAttachmentType::BottomOfTopLineUnderlineTopLine
}
"BottomOfTopLineUnderlineAll" => {
TextAttachmentType::BottomOfTopLineUnderlineAll
}
"CenterOfText" => TextAttachmentType::CenterOfText,
"CenterOfTextOverline" => TextAttachmentType::CenterOfTextOverline,
_ => TextAttachmentType::MiddleOfTopLine,
}
}
let i = self.active_tab;
if let Some(s) = self.mleaderstyle_mut(i) {
match field {
"path_type" => {
s.path_type = match value.as_str() {
"Invisible" => MultiLeaderPathType::Invisible,
"Spline" => MultiLeaderPathType::Spline,
_ => MultiLeaderPathType::StraightLineSegments,
};
}
"content_type" => {
s.content_type = match value.as_str() {
"None" => LeaderContentType::None,
"Block" => LeaderContentType::Block,
"Tolerance" => LeaderContentType::Tolerance,
_ => LeaderContentType::MText,
};
}
"text_angle_type" => {
s.text_angle_type = match value.as_str() {
"ParallelToLastLeaderLine" => {
TextAngleType::ParallelToLastLeaderLine
}
"Optimized" => TextAngleType::Optimized,
_ => TextAngleType::Horizontal,
};
}
"text_alignment" => {
s.text_alignment = match value.as_str() {
"Center" => TextAlignmentType::Center,
"Right" => TextAlignmentType::Right,
_ => TextAlignmentType::Left,
};
}
"text_left_attachment" => s.text_left_attachment = parse_att(&value),
"text_right_attachment" => s.text_right_attachment = parse_att(&value),
"text_top_attachment" => s.text_top_attachment = parse_att(&value),
"text_bottom_attachment" => s.text_bottom_attachment = parse_att(&value),
"text_attachment_direction" => {
s.text_attachment_direction = match value.as_str() {
"Vertical" => TextAttachmentDirectionType::Vertical,
_ => TextAttachmentDirectionType::Horizontal,
};
}
"block_content_connection" => {
s.block_content_connection = match value.as_str() {
"BasePoint" => BlockContentConnectionType::BasePoint,
_ => BlockContentConnectionType::BlockExtents,
};
}
"leader_draw_order" => {
s.leader_draw_order = match value.as_str() {
"LeaderTailFirst" => LeaderDrawOrderType::LeaderTailFirst,
_ => LeaderDrawOrderType::LeaderHeadFirst,
};
}
"multileader_draw_order" => {
s.multileader_draw_order = match value.as_str() {
"LeaderFirst" => MultiLeaderDrawOrderType::LeaderFirst,
_ => MultiLeaderDrawOrderType::ContentFirst,
};
}
_ => {}
}
self.push_undo_snapshot(i, "MLEADERSTYLE EDIT");
self.tabs[i].dirty = true;
self.tabs[i].scene.bump_geometry();
}
Task::none()
}
Message::MLeaderStyleSetHandle { field, value } => {
let i = self.active_tab;
let doc = &self.tabs[i].scene.document;
let handle: Option<acadrust::types::Handle> = if value == "None" {
None
} else {
match field {
"line_type_handle" => doc
.line_types
.iter()
.find(|lt| lt.name == value)
.map(|lt| lt.handle),
"text_style_handle" => doc
.text_styles
.iter()
.find(|t| t.name == value)
.map(|t| t.handle),
"arrowhead_handle" | "block_content_handle" => doc
.block_records
.iter()
.find(|b| b.name == value)
.map(|b| b.handle),
_ => None,
}
};
if let Some(s) = self.mleaderstyle_mut(i) {
match field {
"line_type_handle" => s.line_type_handle = handle,
"text_style_handle" => s.text_style_handle = handle,
"arrowhead_handle" => s.arrowhead_handle = handle,
"block_content_handle" => s.block_content_handle = handle,
_ => {}
}
self.push_undo_snapshot(i, "MLEADERSTYLE EDIT");
self.tabs[i].dirty = true;
self.tabs[i].scene.bump_geometry();
}
Task::none()
}
Message::MLeaderStyleApply => {
let i = self.active_tab;
let (
ld,
lg,
asz,
th,
sf,
bg,
fsa,
ssa,
mp,
dt,
lc,
tc,
) = (
self.mls_landing_distance.parse::<f64>().ok(),
self.mls_landing_gap.parse::<f64>().ok(),
self.mls_arrowhead_size.parse::<f64>().ok(),
self.mls_text_height.parse::<f64>().ok(),
self.mls_scale_factor.parse::<f64>().ok(),
self.mls_break_gap.parse::<f64>().ok(),
self.mls_first_seg_angle.parse::<f64>().ok(),
self.mls_second_seg_angle.parse::<f64>().ok(),
self.mls_max_points.parse::<i32>().ok(),
self.mls_default_text.clone(),
self.mls_line_color.parse::<i16>().ok(),
self.mls_text_color.parse::<i16>().ok(),
);
let desc = self.mls_description.clone();
let lw = self.mls_line_weight.parse::<i16>().ok();
let align = self.mls_align_space.parse::<f64>().ok();
let bclr = self.mls_block_color.parse::<i16>().ok();
let brot = self.mls_block_rotation.parse::<f64>().ok();
let bsx = self.mls_block_scale_x.parse::<f64>().ok();
let bsy = self.mls_block_scale_y.parse::<f64>().ok();
let bsz = self.mls_block_scale_z.parse::<f64>().ok();
if let Some(s) = self.mleaderstyle_mut(i) {
if let Some(v) = ld {
s.landing_distance = v;
}
if let Some(v) = lg {
s.landing_gap = v;
}
if let Some(v) = asz {
s.arrowhead_size = v;
}
if let Some(v) = th {
s.text_height = v;
}
if let Some(v) = sf {
s.scale_factor = v;
}
if let Some(v) = bg {
s.break_gap_size = v;
}
if let Some(v) = fsa {
s.first_segment_angle = v;
}
if let Some(v) = ssa {
s.second_segment_angle = v;
}
if let Some(v) = mp {
s.max_leader_points = v;
}
s.default_text = dt;
if let Some(v) = lc {
s.line_color = acadrust::types::Color::from_index(v);
}
if let Some(v) = tc {
s.text_color = acadrust::types::Color::from_index(v);
}
s.description = desc;
if let Some(v) = lw {
s.line_weight = acadrust::types::LineWeight::from_value(v);
}
if let Some(v) = align {
s.align_space = v;
}
if let Some(v) = bclr {
s.block_content_color = acadrust::types::Color::from_index(v);
}
if let Some(v) = brot {
s.block_content_rotation = v;
}
if let Some(v) = bsx {
s.block_content_scale_x = v;
}
if let Some(v) = bsy {
s.block_content_scale_y = v;
}
if let Some(v) = bsz {
s.block_content_scale_z = v;
}
self.push_undo_snapshot(i, "MLEADERSTYLE EDIT");
self.tabs[i].dirty = true;
self.tabs[i].scene.bump_geometry();
}
Task::none()
}
// ── DimStyle Dialog ───────────────────────────────────────────────
Message::DimStyleDialogOpen => {
let i = self.active_tab;
// Pick the document's current dim style or "Standard".
let cur = self.tabs[i]
.scene
.document
.header
.current_dimstyle_name
.clone();
let selected = if self.tabs[i].scene.document.dim_styles.get(&cur).is_some() {
cur
} else {
self.tabs[i]
.scene
.document
.dim_styles
.iter()
.next()
.map(|s| s.name.clone())
.unwrap_or_else(|| "Standard".to_string())
};
self.dimstyle_selected = selected.clone();
self.load_dimstyle_bufs(i);
if let Some(id) = self.dimstyle_window {
return window::gain_focus(id);
}
let (id, task) = window::open(window::Settings {
size: iced::Size::new(720.0, 560.0),
resizable: true,
level: window::Level::AlwaysOnTop,
..Default::default()
});
self.dimstyle_window = Some(id);
task.map(|_| Message::Noop)
}
Message::DimStyleDialogClose => {
if let Some(id) = self.dimstyle_window.take() {
window::close(id)
} else {
Task::none()
}
}
Message::DimStyleDialogApply => {
let i = self.active_tab;
self.apply_dimstyle_bufs(i);
Task::none()
}
Message::DimStyleDialogSelect(name) => {
let i = self.active_tab;
self.dimstyle_selected = name;
self.load_dimstyle_bufs(i);
Task::none()
}
Message::DimStyleDialogTab(tab) => {
self.dimstyle_tab = tab;
Task::none()
}
Message::DimStyleDialogNew => {
// Delegate to the DIMSTYLE NEW command via command line prompt.
self.command_line.push_info("Enter new DimStyle name:");
if let Some(id) = self.dimstyle_window.take() {
return window::close(id);
}
Task::none()
}
Message::DimStyleDialogSetCurrent => {
let i = self.active_tab;
self.push_undo_snapshot(i, "DIMSTYLE SETCURRENT");
self.tabs[i].scene.document.header.current_dimstyle_name =
self.dimstyle_selected.clone();
self.tabs[i].dirty = true;
self.command_line.push_output(&format!(
"Current dim style set to '{}'.",
self.dimstyle_selected
));
Task::none()
}
Message::DimStyleDialogDelete => {
let i = self.active_tab;
let name = self.dimstyle_selected.clone();
if name == "Standard" {
self.command_line
.push_error("Cannot delete the Standard dim style.");
} else if self.tabs[i]
.scene
.document
.dim_styles
.remove(&name)
.is_some()
{
self.tabs[i].dirty = true;
// Select first remaining style.
self.dimstyle_selected = self.tabs[i]
.scene
.document
.dim_styles
.iter()
.next()
.map(|s| s.name.clone())
.unwrap_or_else(|| "Standard".to_string());
self.load_dimstyle_bufs(i);
self.command_line
.push_output(&format!("DimStyle '{}' deleted.", name));
}
Task::none()
}
Message::DsEdit(field, val) => {
self.apply_ds_edit(field, val);
Task::none()
}
Message::DsToggle(field) => {
self.apply_ds_toggle(field);
Task::none()
}
Message::DsSetHandle { field, value } => {
let i = self.active_tab;
let name = self.dimstyle_selected.clone();
let is_lt = matches!(
field,
"dimltex_handle" | "dimltex1_handle" | "dimltex2_handle"
);
let doc = &self.tabs[i].scene.document;
let handle = if value == "Default" || value == "ByBlock" {
acadrust::types::Handle::NULL
} else if is_lt {
doc.line_types
.iter()
.find(|lt| lt.name == value)
.map(|lt| lt.handle)
.unwrap_or(acadrust::types::Handle::NULL)
} else {
doc.block_records
.iter()
.find(|b| b.name == value)
.map(|b| b.handle)
.unwrap_or(acadrust::types::Handle::NULL)
};
self.push_undo_snapshot(i, "DIMSTYLE EDIT");
if let Some(ds) = self.tabs[i].scene.document.dim_styles.get_mut(&name) {
match field {
"dimblk" => ds.dimblk = handle,
"dimblk1" => ds.dimblk1 = handle,
"dimblk2" => ds.dimblk2 = handle,
"dimldrblk" => ds.dimldrblk = handle,
"dimltex_handle" => ds.dimltex_handle = handle,
"dimltex1_handle" => ds.dimltex1_handle = handle,
"dimltex2_handle" => ds.dimltex2_handle = handle,
_ => {}
}
}
self.tabs[i].dirty = true;
self.tabs[i].scene.bump_geometry();
Task::none()
}
}
}
}
// ── DimStyle dialog helpers ─────────────────────────────────────────────────
impl OpenCADStudio {
/// Rebuild the active tab's dynamic-input field set to match what the
/// command is currently asking for. Called on cursor move and after
/// command-state changes. The field set only changes shape when the
/// command's `dyn_field()` or the presence of a base point changes;
/// existing typed buffers survive an unchanged shape.
fn sync_dyn_fields(&mut self) {
use super::document::{DynComponent, DynFieldEntry};
let i = self.active_tab;
if !self.dyn_input || self.tabs[i].active_cmd.is_none() {
self.tabs[i].dyn_fields.clear();
self.tabs[i].dyn_active = 0;
return;
}
let field = self
.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.dyn_field())
.unwrap_or(crate::command::DynField::Point);
let has_base = self.last_point.is_some();
let default: Vec<DynComponent> = match field {
crate::command::DynField::Distance => vec![DynComponent::Distance],
crate::command::DynField::Angle => vec![DynComponent::Angle],
crate::command::DynField::Point if has_base => {
vec![DynComponent::Distance, DynComponent::Angle]
}
crate::command::DynField::Point => vec![DynComponent::X, DynComponent::Y],
};
// Multiple shapes can satisfy the same command request — e.g. a
// `Point` is happy with either `[Distance, Angle]` (polar) or
// `[X, Y]` / `[X, Y, Z]` (cartesian). If the user already
// reshaped via `,` (see #35) the existing set is still a valid
// Point configuration and must not be reverted on every mouse
// move.
let current: Vec<DynComponent> =
self.tabs[i].dyn_fields.iter().map(|f| f.component).collect();
// Only treat a cartesian / polar variant as "good enough to keep"
// when the user explicitly reshaped via `,`. Otherwise we follow
// the command's default so e.g. clicking the first point of LINE
// flips a stale `[X, Y]` (from before there was a base) over to
// the polar `[Distance, Angle]` the prompt actually wants.
let current_is_acceptable = if self.dyn_user_reshaped {
match field {
crate::command::DynField::Distance => {
matches!(current.as_slice(), [DynComponent::Distance])
}
crate::command::DynField::Angle => {
matches!(current.as_slice(), [DynComponent::Angle])
}
crate::command::DynField::Point => matches!(
current.as_slice(),
[DynComponent::Distance, DynComponent::Angle]
| [DynComponent::X, DynComponent::Y]
| [DynComponent::X, DynComponent::Y, DynComponent::Z]
),
}
} else {
current == default
};
if !current_is_acceptable {
self.tabs[i].dyn_fields = default.into_iter().map(DynFieldEntry::new).collect();
self.tabs[i].dyn_active = 0;
}
}
/// Track cursor dwell over a selected entity's grip. Sets
/// `grip_hover` while the cursor sits within `GRIP_THRESHOLD_PX` of
/// a grip and opens `grip_popup` once the dwell exceeds the
/// threshold. Cursor drift clears both.
/// After the active Model tile changes, mirror its stored visual style
/// into the tab so the picker shows it and the tile renders with it
/// (the active tile draws with the tab's live render mode).
fn sync_render_mode_to_active_tile(&mut self, i: usize) {
use acadrust::entities::ViewportRenderMode as M;
if self.tabs[i].scene.current_layout != "Model" {
return;
}
let mode = self.tabs[i].scene.active_model_tile_render_mode();
if self.tabs[i].render_mode == mode {
return;
}
let label = match mode {
M::Wireframe2D => "Wireframe 2D",
M::Wireframe3D => "Wireframe 3D",
M::HiddenLine => "Hidden Line",
M::FlatShaded => "Flat Shaded",
M::GouraudShaded => "Gouraud Shaded",
M::FlatShadedWithEdges => "Flat Shaded + Edges",
M::GouraudShadedWithEdges => "Gouraud Shaded + Edges",
};
self.tabs[i].render_mode = mode;
let wf = matches!(mode, M::Wireframe2D | M::Wireframe3D);
self.tabs[i].wireframe = wf;
self.ribbon.set_wireframe(wf);
self.tabs[i].visual_style = label.into();
self.tabs[i].scene.bump_geometry();
}
fn update_grip_hover(&mut self, i: usize, p: iced::Point) {
const HOVER_OPEN_MS: u128 = 600;
const POPUP_DISMISS_PX: f32 = 80.0;
if self.tabs[i].active_cmd.is_some()
|| self.tabs[i].active_grip.is_some()
|| self.tabs[i].selected_grips.is_empty()
{
self.grip_hover = None;
self.grip_popup = None;
return;
}
let Some(handle) = self.tabs[i].selected_handle else {
self.grip_hover = None;
self.grip_popup = None;
return;
};
let (vw, vh) = self.tabs[i].scene.selection.borrow().vp_size;
let bounds = iced::Rectangle {
x: 0.0,
y: 0.0,
width: vw,
height: vh,
};
let is_paper = self.tabs[i].scene.current_layout != "Model";
let hit = if is_paper {
let cam = self.tabs[i].scene.camera.borrow();
let aspect = if vh > 0.0 { vw / vh } else { 1.0 };
let half_h = cam.ortho_size();
let half_w = half_h * aspect;
let tx = cam.target.x;
let ty = cam.target.y;
drop(cam);
find_hit_grip_paper(
p,
&self.tabs[i].selected_grips,
tx,
ty,
half_w,
half_h,
bounds,
)
} else {
let vp_mat = self.tabs[i].scene.camera.borrow().view_proj(bounds);
find_hit_grip(p, &self.tabs[i].selected_grips, vp_mat, bounds)
};
match hit {
Some((grip_id, _, _)) => {
let same = self
.grip_hover
.as_ref()
.map_or(false, |h| h.handle == handle && h.grip_id == grip_id);
if !same {
self.grip_hover = Some(super::GripHover {
handle,
grip_id,
screen: p,
started: std::time::Instant::now(),
});
self.grip_popup = None;
} else if let Some(h) = self.grip_hover.as_mut() {
h.screen = p;
}
// Open popup once dwell crosses the threshold.
if self.grip_popup.is_none()
&& self
.grip_hover
.as_ref()
.map_or(false, |h| h.started.elapsed().as_millis() >= HOVER_OPEN_MS)
{
let entity_opt = self.tabs[i].scene.document.get_entity(handle);
if let Some(e) = entity_opt {
use crate::entities::traits::EntityTypeOps;
let items = e.grip_menu(grip_id);
if !items.is_empty() {
self.grip_popup = Some(super::GripPopup {
handle,
grip_id,
anchor: p,
items,
selected: 0,
});
}
}
}
}
None => {
self.grip_hover = None;
if let Some(popup) = &self.grip_popup {
let dx = p.x - popup.anchor.x;
let dy = p.y - popup.anchor.y;
if (dx * dx + dy * dy).sqrt() > POPUP_DISMISS_PX {
self.grip_popup = None;
}
}
}
}
}
/// Re-run the active command's preview hook against the current
/// cursor world position. Keyboard-driven point commits (typed
/// coordinates in the command line or dynamic input) don't fire a
/// mouse-move event, so without this the rubber-band preview keeps
/// dangling from the previous `last_point` until the user actually
/// moves the mouse. See #32.
fn refresh_active_cmd_preview(&mut self, i: usize) {
if self.tabs[i].active_cmd.is_none() {
return;
}
let cur = self.tabs[i].last_cursor_world;
let previews = self.tabs[i]
.active_cmd
.as_mut()
.map(|c| c.on_preview_wires(cur))
.unwrap_or_default();
self.tabs[i].scene.set_preview_wires(previews);
}
/// Resolve the world point implied by the current dynamic-input field
/// values. Locked fields use their typed buffer; the rest fall back to
/// the live cursor-derived value. Returns `None` when the field set
/// isn't one we know how to turn into a point.
fn dyn_resolve_point(&self) -> Option<glam::Vec3> {
use super::document::DynComponent;
let i = self.active_tab;
let fields = &self.tabs[i].dyn_fields;
if fields.is_empty() {
return None;
}
let w = self.tabs[i].last_cursor_world;
let base = self.last_point.unwrap_or(glam::Vec3::ZERO);
// Buffer value parsed as f32, or the supplied live value.
let val = |idx: usize, live: f32| -> f32 {
fields[idx]
.buffer
.as_ref()
.and_then(|s| s.trim().replace(',', ".").parse::<f32>().ok())
.unwrap_or(live)
};
let dx = w.x - base.x;
let dy = w.y - base.y;
let live_d = (dx * dx + dy * dy).sqrt();
let live_a = dy.atan2(dx); // radians
let comps: Vec<DynComponent> = fields.iter().map(|f| f.component).collect();
// DYN-on defaults to RELATIVE coordinates when a base point is set
// (see #26 / #35). The live cartesian fallback is the cursor
// position relative to base; typed values are relative deltas.
let has_base = self.last_point.is_some();
match comps.as_slice() {
[DynComponent::X, DynComponent::Y] if has_base => {
Some(glam::Vec3::new(base.x + val(0, dx), base.y + val(1, dy), base.z))
}
[DynComponent::X, DynComponent::Y] => {
Some(glam::Vec3::new(val(0, w.x), val(1, w.y), base.z))
}
[DynComponent::X, DynComponent::Y, DynComponent::Z] if has_base => {
Some(glam::Vec3::new(
base.x + val(0, dx),
base.y + val(1, dy),
base.z + val(2, 0.0),
))
}
[DynComponent::X, DynComponent::Y, DynComponent::Z] => Some(glam::Vec3::new(
val(0, w.x),
val(1, w.y),
val(2, base.z),
)),
[DynComponent::Distance, DynComponent::Angle] => {
let d = val(0, live_d);
let a = val(1, live_a.to_degrees()).to_radians();
Some(glam::Vec3::new(base.x + d * a.cos(), base.y + d * a.sin(), base.z))
}
[DynComponent::Distance] => {
// Keep the cursor's direction, override the magnitude.
let dir = glam::Vec3::new(dx, dy, 0.0).normalize_or(glam::Vec3::X);
Some(base + dir * val(0, live_d))
}
[DynComponent::Angle] => {
// Keep the cursor's distance, override the angle.
let a = val(0, live_a.to_degrees()).to_radians();
Some(glam::Vec3::new(
base.x + live_d * a.cos(),
base.y + live_d * a.sin(),
base.z,
))
}
_ => None,
}
}
/// Handle `,` while a dynamic-input field set is showing. Locks the
/// current field's buffer if it has one, then either advances within
/// the existing field set or reshapes it: a polar `[Distance, Angle]`
/// configuration becomes cartesian `[X(buf), Y]`, and a cartesian
/// `[X, Y]` configuration extends to `[X, Y, Z]`. Default fallthrough
/// is "advance to next field", matching `Tab`. See #35.
fn dyn_comma_advance(&mut self) {
use super::document::{DynComponent, DynFieldEntry};
let i = self.active_tab;
if self.tabs[i].dyn_fields.is_empty() {
return;
}
// The user picked a shape — `sync_dyn_fields` preserves it until
// the next commit / command-start clears the flag.
self.dyn_user_reshaped = true;
let active = self
.tabs[i]
.dyn_active
.min(self.tabs[i].dyn_fields.len() - 1);
let comps: Vec<DynComponent> = self
.tabs[i]
.dyn_fields
.iter()
.map(|f| f.component)
.collect();
let cur_buf = self.tabs[i].dyn_fields[active].buffer.clone();
match (comps.as_slice(), active) {
// First polar field — `,` switches to cartesian, locking the
// typed value as X.
([DynComponent::Distance, DynComponent::Angle], 0)
| ([DynComponent::Distance], 0) => {
let mut x_field = DynFieldEntry::new(DynComponent::X);
x_field.buffer = cur_buf;
self.tabs[i].dyn_fields =
vec![x_field, DynFieldEntry::new(DynComponent::Y)];
self.tabs[i].dyn_active = 1;
}
// Already cartesian X (first field) — just advance to Y.
([DynComponent::X, DynComponent::Y], 0)
| ([DynComponent::X, DynComponent::Y, DynComponent::Z], 0) => {
self.tabs[i].dyn_active = 1;
}
// Cartesian Y — extend to 3-D by appending Z.
([DynComponent::X, DynComponent::Y], 1) => {
self.tabs[i]
.dyn_fields
.push(DynFieldEntry::new(DynComponent::Z));
self.tabs[i].dyn_active = 2;
}
// Cartesian Y in the 3-D set — advance to Z.
([DynComponent::X, DynComponent::Y, DynComponent::Z], 1) => {
self.tabs[i].dyn_active = 2;
}
// Z, Angle, or any singleton: nothing further to advance to.
_ => {}
}
}
/// If dynamic input has at least one locked (typed) field, resolve the
/// implied point, feed it to the active command as a point pick, reset
/// the field buffers, and return the resulting task. Returns `None`
/// when there is nothing typed, so the caller falls back to its normal
/// Enter handling.
fn try_dyn_commit(&mut self) -> Option<Task<Message>> {
let i = self.active_tab;
if !self.dyn_input
|| self.tabs[i].active_cmd.is_none()
|| self.tabs[i].dyn_fields.is_empty()
|| !self.tabs[i].dyn_fields.iter().any(|f| f.locked())
{
return None;
}
let pt = self.dyn_resolve_point()?;
self.last_point = Some(pt);
self.dyn_user_reshaped = false;
self.sync_dyn_fields();
let result = self.tabs[i].active_cmd.as_mut().map(|c| c.on_point(pt));
for f in self.tabs[i].dyn_fields.iter_mut() {
f.buffer = None;
}
self.tabs[i].dyn_active = 0;
let task = result.map(|r| self.apply_cmd_result(r))?;
// Match the command-line path: refresh the rubber-band preview
// so the next segment immediately starts from the new
// last_point even though no mouse-move fires after a typed
// coordinate. See #32.
self.refresh_active_cmd_preview(i);
Some(task)
}
/// Mutable access to the currently selected table style.
fn tablestyle_mut(&mut self, tab: usize) -> Option<&mut acadrust::objects::TableStyle> {
use acadrust::objects::ObjectType;
let name = self.tablestyle_selected.clone();
self.tabs[tab]
.scene
.document
.objects
.values_mut()
.find_map(|o| match o {
ObjectType::TableStyle(s) if s.name == name => Some(s),
_ => None,
})
}
/// Mutable access to a table style's cell style by row (0=Data,1=Header,2=Title).
fn ts_cell_of(
s: &mut acadrust::objects::TableStyle,
row: u8,
) -> Option<&mut acadrust::objects::RowCellStyle> {
match row {
0 => Some(&mut s.data_row_style),
1 => Some(&mut s.header_row_style),
2 => Some(&mut s.title_row_style),
_ => None,
}
}
/// Mutable access to a cell's border by index
/// (0=left 1=right 2=top 3=bottom 4=horizontal-inside 5=vertical-inside).
fn ts_border_of(
c: &mut acadrust::objects::RowCellStyle,
border: u8,
) -> Option<&mut acadrust::objects::TableCellBorder> {
match border {
0 => Some(&mut c.left_border),
1 => Some(&mut c.right_border),
2 => Some(&mut c.top_border),
3 => Some(&mut c.bottom_border),
4 => Some(&mut c.horizontal_inside_border),
5 => Some(&mut c.vertical_inside_border),
_ => None,
}
}
/// Populate margin + per-cell edit buffers from the selected table style.
fn load_tablestyle_bufs(&mut self, tab: usize) {
use acadrust::objects::ObjectType;
let name = self.tablestyle_selected.clone();
let Some(s) = self.tabs[tab]
.scene
.document
.objects
.values()
.find_map(|o| match o {
ObjectType::TableStyle(s) if s.name == name => Some(s),
_ => None,
})
else {
return;
};
self.ts_hmargin = format!("{:.4}", s.horizontal_margin);
self.ts_vmargin = format!("{:.4}", s.vertical_margin);
self.ts_description = s.description.clone();
for (r, c) in [&s.data_row_style, &s.header_row_style, &s.title_row_style]
.into_iter()
.enumerate()
{
self.ts_cell_textstyle[r] = c.text_style_name.clone();
self.ts_cell_height[r] = format!("{:.4}", c.text_height);
self.ts_cell_textcolor[r] =
c.text_color.index().map(|v| v.to_string()).unwrap_or_default();
self.ts_cell_fillcolor[r] =
c.fill_color.index().map(|v| v.to_string()).unwrap_or_default();
self.ts_cell_datatype[r] = c.data_type.to_string();
self.ts_cell_unittype[r] = c.unit_type.to_string();
self.ts_cell_format[r] = c.format_string.clone();
let borders = [
&c.left_border,
&c.right_border,
&c.top_border,
&c.bottom_border,
&c.horizontal_inside_border,
&c.vertical_inside_border,
];
for (b, bd) in borders.into_iter().enumerate() {
self.ts_border_lw[r][b] = bd.line_weight.value().to_string();
self.ts_border_color[r][b] =
bd.color.index().map(|v| v.to_string()).unwrap_or_default();
self.ts_border_spacing[r][b] = format!("{:.4}", bd.double_line_spacing);
}
}
}
/// Mutable access to the currently selected multileader style.
fn mleaderstyle_mut(
&mut self,
tab: usize,
) -> Option<&mut acadrust::objects::MultiLeaderStyle> {
use acadrust::objects::ObjectType;
let name = self.mleaderstyle_selected.clone();
self.tabs[tab]
.scene
.document
.objects
.values_mut()
.find_map(|o| match o {
ObjectType::MultiLeaderStyle(s) if s.name == name => Some(s),
_ => None,
})
}
/// Populate all edit buffers from the currently selected multileader style.
fn load_mleaderstyle_bufs(&mut self, tab: usize) {
use acadrust::objects::ObjectType;
let name = self.mleaderstyle_selected.clone();
let Some(s) = self.tabs[tab]
.scene
.document
.objects
.values()
.find_map(|o| match o {
ObjectType::MultiLeaderStyle(s) if s.name == name => Some(s),
_ => None,
})
else {
return;
};
self.mls_landing_distance = format!("{:.4}", s.landing_distance);
self.mls_landing_gap = format!("{:.4}", s.landing_gap);
self.mls_arrowhead_size = format!("{:.4}", s.arrowhead_size);
self.mls_text_height = format!("{:.4}", s.text_height);
self.mls_scale_factor = format!("{:.4}", s.scale_factor);
self.mls_break_gap = format!("{:.4}", s.break_gap_size);
self.mls_first_seg_angle = format!("{:.4}", s.first_segment_angle);
self.mls_second_seg_angle = format!("{:.4}", s.second_segment_angle);
self.mls_max_points = s.max_leader_points.to_string();
self.mls_default_text = s.default_text.clone();
self.mls_line_color = s
.line_color
.index()
.map(|c| c.to_string())
.unwrap_or_default();
self.mls_text_color = s
.text_color
.index()
.map(|c| c.to_string())
.unwrap_or_default();
self.mls_description = s.description.clone();
self.mls_line_weight = s.line_weight.value().to_string();
self.mls_align_space = format!("{:.4}", s.align_space);
self.mls_block_color = s
.block_content_color
.index()
.map(|c| c.to_string())
.unwrap_or_default();
self.mls_block_rotation = format!("{:.4}", s.block_content_rotation);
self.mls_block_scale_x = format!("{:.4}", s.block_content_scale_x);
self.mls_block_scale_y = format!("{:.4}", s.block_content_scale_y);
self.mls_block_scale_z = format!("{:.4}", s.block_content_scale_z);
}
/// Populate all edit buffers from the currently selected dim style.
fn load_dimstyle_bufs(&mut self, tab: usize) {
let doc = &self.tabs[tab].scene.document;
let Some(ds) = doc.dim_styles.get(&self.dimstyle_selected) else {
return;
};
self.ds_dimdle = format!("{}", ds.dimdle);
self.ds_dimdli = format!("{}", ds.dimdli);
self.ds_dimgap = format!("{}", ds.dimgap);
self.ds_dimexe = format!("{}", ds.dimexe);
self.ds_dimexo = format!("{}", ds.dimexo);
self.ds_dimsd1 = ds.dimsd1;
self.ds_dimsd2 = ds.dimsd2;
self.ds_dimse1 = ds.dimse1;
self.ds_dimse2 = ds.dimse2;
self.ds_dimasz = format!("{}", ds.dimasz);
self.ds_dimcen = format!("{}", ds.dimcen);
self.ds_dimtsz = format!("{}", ds.dimtsz);
self.ds_dimtxt = format!("{}", ds.dimtxt);
self.ds_dimtxsty = ds.dimtxsty.clone();
self.ds_dimtad = format!("{}", ds.dimtad);
self.ds_dimtih = ds.dimtih;
self.ds_dimtoh = ds.dimtoh;
self.ds_dimscale = format!("{}", ds.dimscale);
self.ds_dimlfac = format!("{}", ds.dimlfac);
self.ds_dimlunit = format!("{}", ds.dimlunit);
self.ds_dimdec = format!("{}", ds.dimdec);
self.ds_dimpost = ds.dimpost.clone();
self.ds_dimtol = ds.dimtol;
self.ds_dimlim = ds.dimlim;
self.ds_dimtp = format!("{}", ds.dimtp);
self.ds_dimtm = format!("{}", ds.dimtm);
self.ds_dimtdec = format!("{}", ds.dimtdec);
self.ds_dimtfac = format!("{}", ds.dimtfac);
self.ds_annotative = ds.annotative;
self.ds_dimclrd = format!("{}", ds.dimclrd);
self.ds_dimlwd = format!("{}", ds.dimlwd);
self.ds_dimclre = format!("{}", ds.dimclre);
self.ds_dimlwe = format!("{}", ds.dimlwe);
self.ds_dimfxl = format!("{}", ds.dimfxl);
self.ds_dimfxlon = ds.dimfxlon;
self.ds_dimsah = ds.dimsah;
self.ds_dimarcsym = format!("{}", ds.dimarcsym);
self.ds_dimjogang = format!("{}", ds.dimjogang.to_degrees());
self.ds_dimclrt = format!("{}", ds.dimclrt);
self.ds_dimjust = format!("{}", ds.dimjust);
self.ds_dimtvp = format!("{}", ds.dimtvp);
self.ds_dimtfill = format!("{}", ds.dimtfill);
self.ds_dimtfillclr = format!("{}", ds.dimtfillclr);
self.ds_dimtxtdirection = ds.dimtxtdirection;
self.ds_dimatfit = format!("{}", ds.dimatfit);
self.ds_dimtix = ds.dimtix;
self.ds_dimsoxd = ds.dimsoxd;
self.ds_dimtmove = format!("{}", ds.dimtmove);
self.ds_dimupt = ds.dimupt;
self.ds_dimtofl = ds.dimtofl;
self.ds_dimfit = format!("{}", ds.dimfit);
self.ds_dimdsep = format!("{}", ds.dimdsep);
self.ds_dimrnd = format!("{}", ds.dimrnd);
self.ds_dimzin = format!("{}", ds.dimzin);
self.ds_dimfrac = format!("{}", ds.dimfrac);
self.ds_dimaunit = format!("{}", ds.dimaunit);
self.ds_dimadec = format!("{}", ds.dimadec);
self.ds_dimunit = format!("{}", ds.dimunit);
self.ds_dimazin = format!("{}", ds.dimazin);
self.ds_dimalt = ds.dimalt;
self.ds_dimaltf = format!("{}", ds.dimaltf);
self.ds_dimaltd = format!("{}", ds.dimaltd);
self.ds_dimaltu = format!("{}", ds.dimaltu);
self.ds_dimalttd = format!("{}", ds.dimalttd);
self.ds_dimaltrnd = format!("{}", ds.dimaltrnd);
self.ds_dimapost = ds.dimapost.clone();
self.ds_dimaltz = format!("{}", ds.dimaltz);
self.ds_dimalttz = format!("{}", ds.dimalttz);
self.ds_dimtolj = format!("{}", ds.dimtolj);
self.ds_dimtzin = format!("{}", ds.dimtzin);
}
/// Write edit buffers back into the selected dim style document entry.
fn apply_dimstyle_bufs(&mut self, tab: usize) {
self.push_undo_snapshot(tab, "DIMSTYLE EDIT");
let doc = &mut self.tabs[tab].scene.document;
let Some(ds) = doc.dim_styles.get_mut(&self.dimstyle_selected) else {
return;
};
macro_rules! set_f64 {
($field:ident, $buf:expr) => {
if let Ok(v) = $buf.trim().parse::<f64>() {
ds.$field = v;
}
};
}
macro_rules! set_i16 {
($field:ident, $buf:expr) => {
if let Ok(v) = $buf.trim().parse::<i16>() {
ds.$field = v;
}
};
}
set_f64!(dimdle, self.ds_dimdle);
set_f64!(dimdli, self.ds_dimdli);
set_f64!(dimgap, self.ds_dimgap);
set_f64!(dimexe, self.ds_dimexe);
set_f64!(dimexo, self.ds_dimexo);
set_f64!(dimasz, self.ds_dimasz);
set_f64!(dimcen, self.ds_dimcen);
set_f64!(dimtsz, self.ds_dimtsz);
set_f64!(dimtxt, self.ds_dimtxt);
set_f64!(dimscale, self.ds_dimscale);
set_f64!(dimlfac, self.ds_dimlfac);
set_f64!(dimtp, self.ds_dimtp);
set_f64!(dimtm, self.ds_dimtm);
set_f64!(dimtfac, self.ds_dimtfac);
set_i16!(dimtad, self.ds_dimtad);
set_i16!(dimlunit, self.ds_dimlunit);
set_i16!(dimdec, self.ds_dimdec);
set_i16!(dimtdec, self.ds_dimtdec);
ds.dimsd1 = self.ds_dimsd1;
ds.dimsd2 = self.ds_dimsd2;
ds.dimse1 = self.ds_dimse1;
ds.dimse2 = self.ds_dimse2;
ds.dimtih = self.ds_dimtih;
ds.dimtoh = self.ds_dimtoh;
ds.dimtol = self.ds_dimtol;
ds.dimlim = self.ds_dimlim;
ds.dimpost = self.ds_dimpost.clone();
ds.dimtxsty = self.ds_dimtxsty.clone();
ds.annotative = self.ds_annotative;
set_i16!(dimclrd, self.ds_dimclrd);
set_i16!(dimlwd, self.ds_dimlwd);
set_i16!(dimclre, self.ds_dimclre);
set_i16!(dimlwe, self.ds_dimlwe);
set_f64!(dimfxl, self.ds_dimfxl);
set_i16!(dimarcsym, self.ds_dimarcsym);
set_i16!(dimclrt, self.ds_dimclrt);
set_i16!(dimjust, self.ds_dimjust);
set_f64!(dimtvp, self.ds_dimtvp);
set_i16!(dimtfill, self.ds_dimtfill);
set_i16!(dimtfillclr, self.ds_dimtfillclr);
set_i16!(dimatfit, self.ds_dimatfit);
set_i16!(dimtmove, self.ds_dimtmove);
set_i16!(dimfit, self.ds_dimfit);
set_i16!(dimdsep, self.ds_dimdsep);
set_f64!(dimrnd, self.ds_dimrnd);
set_i16!(dimzin, self.ds_dimzin);
set_i16!(dimfrac, self.ds_dimfrac);
set_i16!(dimaunit, self.ds_dimaunit);
set_i16!(dimadec, self.ds_dimadec);
set_i16!(dimunit, self.ds_dimunit);
set_i16!(dimazin, self.ds_dimazin);
set_f64!(dimaltf, self.ds_dimaltf);
set_i16!(dimaltd, self.ds_dimaltd);
set_i16!(dimaltu, self.ds_dimaltu);
set_i16!(dimalttd, self.ds_dimalttd);
set_f64!(dimaltrnd, self.ds_dimaltrnd);
set_i16!(dimaltz, self.ds_dimaltz);
set_i16!(dimalttz, self.ds_dimalttz);
set_i16!(dimtolj, self.ds_dimtolj);
set_i16!(dimtzin, self.ds_dimtzin);
if let Ok(v) = self.ds_dimjogang.trim().parse::<f64>() {
ds.dimjogang = v.to_radians();
}
ds.dimfxlon = self.ds_dimfxlon;
ds.dimsah = self.ds_dimsah;
ds.dimtxtdirection = self.ds_dimtxtdirection;
ds.dimtix = self.ds_dimtix;
ds.dimsoxd = self.ds_dimsoxd;
ds.dimupt = self.ds_dimupt;
ds.dimtofl = self.ds_dimtofl;
ds.dimalt = self.ds_dimalt;
ds.dimapost = self.ds_dimapost.clone();
self.tabs[tab].dirty = true;
self.command_line
.push_output(&format!("DimStyle '{}' updated.", self.dimstyle_selected));
}
/// Update a single string buffer field.
fn apply_ds_edit(&mut self, field: super::DsField, val: String) {
use super::DsField::*;
match field {
Dimdle => self.ds_dimdle = val,
Dimdli => self.ds_dimdli = val,
Dimgap => self.ds_dimgap = val,
Dimexe => self.ds_dimexe = val,
Dimexo => self.ds_dimexo = val,
Dimasz => self.ds_dimasz = val,
Dimcen => self.ds_dimcen = val,
Dimtsz => self.ds_dimtsz = val,
Dimtxt => self.ds_dimtxt = val,
Dimtxsty => self.ds_dimtxsty = val,
Dimtad => self.ds_dimtad = val,
Dimscale => self.ds_dimscale = val,
Dimlfac => self.ds_dimlfac = val,
Dimlunit => self.ds_dimlunit = val,
Dimdec => self.ds_dimdec = val,
Dimpost => self.ds_dimpost = val,
Dimtp => self.ds_dimtp = val,
Dimtm => self.ds_dimtm = val,
Dimtdec => self.ds_dimtdec = val,
Dimtfac => self.ds_dimtfac = val,
Dimclrd => self.ds_dimclrd = val,
Dimlwd => self.ds_dimlwd = val,
Dimclre => self.ds_dimclre = val,
Dimlwe => self.ds_dimlwe = val,
Dimfxl => self.ds_dimfxl = val,
Dimarcsym => self.ds_dimarcsym = val,
Dimjogang => self.ds_dimjogang = val,
Dimclrt => self.ds_dimclrt = val,
Dimjust => self.ds_dimjust = val,
Dimtvp => self.ds_dimtvp = val,
Dimtfill => self.ds_dimtfill = val,
Dimtfillclr => self.ds_dimtfillclr = val,
Dimatfit => self.ds_dimatfit = val,
Dimtmove => self.ds_dimtmove = val,
Dimfit => self.ds_dimfit = val,
Dimdsep => self.ds_dimdsep = val,
Dimrnd => self.ds_dimrnd = val,
Dimzin => self.ds_dimzin = val,
Dimfrac => self.ds_dimfrac = val,
Dimaunit => self.ds_dimaunit = val,
Dimadec => self.ds_dimadec = val,
Dimunit => self.ds_dimunit = val,
Dimazin => self.ds_dimazin = val,
Dimaltf => self.ds_dimaltf = val,
Dimaltd => self.ds_dimaltd = val,
Dimaltu => self.ds_dimaltu = val,
Dimalttd => self.ds_dimalttd = val,
Dimaltrnd => self.ds_dimaltrnd = val,
Dimapost => self.ds_dimapost = val,
Dimaltz => self.ds_dimaltz = val,
Dimalttz => self.ds_dimalttz = val,
Dimtolj => self.ds_dimtolj = val,
Dimtzin => self.ds_dimtzin = val,
// Bool fields — no-op for string edit
_ => {}
}
}
/// Toggle a boolean buffer field.
fn apply_ds_toggle(&mut self, field: super::DsField) {
use super::DsField::*;
match field {
Dimsd1 => self.ds_dimsd1 = !self.ds_dimsd1,
Dimsd2 => self.ds_dimsd2 = !self.ds_dimsd2,
Dimse1 => self.ds_dimse1 = !self.ds_dimse1,
Dimse2 => self.ds_dimse2 = !self.ds_dimse2,
Dimtih => self.ds_dimtih = !self.ds_dimtih,
Dimtoh => self.ds_dimtoh = !self.ds_dimtoh,
Dimtol => self.ds_dimtol = !self.ds_dimtol,
Dimlim => self.ds_dimlim = !self.ds_dimlim,
Annotative => self.ds_annotative = !self.ds_annotative,
Dimfxlon => self.ds_dimfxlon = !self.ds_dimfxlon,
Dimsah => self.ds_dimsah = !self.ds_dimsah,
Dimtxtdirection => self.ds_dimtxtdirection = !self.ds_dimtxtdirection,
Dimtix => self.ds_dimtix = !self.ds_dimtix,
Dimsoxd => self.ds_dimsoxd = !self.ds_dimsoxd,
Dimupt => self.ds_dimupt = !self.ds_dimupt,
Dimtofl => self.ds_dimtofl = !self.ds_dimtofl,
Dimalt => self.ds_dimalt = !self.ds_dimalt,
_ => {}
}
}
/// Populate edit buffers from the currently selected text style.
fn open_save_dialog_window(&mut self, tab_idx: usize) -> Task<Message> {
if let Some(id) = self.save_dialog_window {
return window::gain_focus(id);
}
// Pre-fill filename and folder from current path or defaults.
if let Some(p) = &self.tabs[tab_idx].current_path.clone() {
if let Some(name) = p.file_name() {
self.save_dialog_filename = name.to_string_lossy().into_owned();
}
if let Some(dir) = p.parent() {
self.save_dialog_folder = dir.to_path_buf();
}
} else {
let (ext, _) = crate::io::parse_save_format(&self.save_dialog_format);
self.save_dialog_filename = format!("{}.{ext}", self.tabs[tab_idx].tab_display_name());
}
self.save_dialog_entries = crate::io::read_dir_entries(&self.save_dialog_folder.clone());
let (id, task) = window::open(window::Settings {
size: iced::Size::new(560.0, 480.0),
resizable: true,
level: window::Level::AlwaysOnTop,
..Default::default()
});
self.save_dialog_window = Some(id);
task.map(|_| Message::Noop)
}
fn close_save_dialog_window(&mut self) -> Task<Message> {
if let Some(id) = self.save_dialog_window.take() {
window::close(id)
} else {
Task::none()
}
}
fn open_unsaved_dialog_window(&mut self) -> Task<Message> {
if let Some(id) = self.unsaved_dialog_window {
return window::gain_focus(id);
}
let (id, task) = window::open(window::Settings {
size: iced::Size::new(420.0, 155.0),
resizable: false,
level: window::Level::AlwaysOnTop,
..Default::default()
});
self.unsaved_dialog_window = Some(id);
task.map(|_| Message::Noop)
}
fn close_unsaved_dialog_window(&mut self) -> Task<Message> {
if let Some(id) = self.unsaved_dialog_window.take() {
window::close(id)
} else {
Task::none()
}
}
fn load_textstyle_bufs(&mut self, tab: usize) {
let doc = &self.tabs[tab].scene.document;
if let Some(s) = doc.text_styles.get(&self.textstyle_selected) {
self.textstyle_font = s.font_file.clone();
self.textstyle_width = format!("{:.4}", s.width_factor);
self.textstyle_oblique = format!("{:.2}", s.oblique_angle.to_degrees());
self.textstyle_height = format!("{:.4}", s.height);
self.textstyle_bigfont = s.big_font_file.clone();
self.textstyle_ttf = s.true_type_font.clone();
}
}
}
/// Parse a scale string like "1:50" or "2:1" into (numerator, denominator).
/// Returns (1.0, 1.0) for "Fit" or unknown formats.
/// Sync the model-space annotation scale into the standard CANNOSCALE /
/// CANNOSCALEVALUE header variables before a save, so the scale round-trips
/// through the file (and is read correctly by other CAD applications).
fn sync_annotation_scale_header(scene: &mut Scene) {
let anno = scene.annotation_scale;
let value = if anno.abs() > 1e-9 { 1.0 / anno as f64 } else { 1.0 };
// Prefer the name of a matching scale already in the drawing's list;
// fall back to a formatted ratio when none matches.
let name = scene
.scale_list()
.into_iter()
.find(|(_, a, _)| (a - anno).abs() < 0.001 * anno.max(0.001))
.map(|(n, _, _)| n)
.unwrap_or_else(|| format_annotation_scale_name(anno));
let hdr = &mut scene.document.header;
hdr.current_annotation_scale = name;
hdr.annotation_scale_value = value;
}
/// Format an annotation-scale multiplier as a ratio name: 50.0 -> "1:50",
/// 0.5 -> "2:1", 1.0 -> "1:1".
fn format_annotation_scale_name(anno: f32) -> String {
if anno >= 1.0 {
format!("1:{}", anno.round() as i64)
} else if anno > 0.0 {
format!("{}:1", (1.0 / anno).round() as i64)
} else {
"1:1".to_string()
}
}
fn parse_plot_scale(s: &str) -> (f64, f64) {
if s == "Fit" {
return (1.0, 1.0);
}
if let Some((a, b)) = s.split_once(':') {
let num: f64 = a.trim().parse().unwrap_or(1.0);
let den: f64 = b.trim().parse().unwrap_or(1.0);
if den > 0.0 {
return (num, den);
}
}
(1.0, 1.0)
}