forked from 1b-agent/opencadstudio-upstream
refactor: split four oversized files into submodule trees
Break up the four giant files (~29k lines total) into focused submodule
directories, one concern per file. Pure mechanical moves — behavior is
unchanged.
- app/update.rs (9650) -> app/update/{mod,viewport,command,style,file,
dynamic,dialog,util}. The 384-arm update_inner match stays in mod.rs
with thin delegating arms; the 66 fattest arm bodies move to topic
methods. Arm set preserved exactly (384 -> 384).
- scene/mod.rs (9017) -> scene/{mod,entity,tess,hittest,layout,paper,
mspace,project,selection,modify,group_layer,preview}. The impl Scene
body is split across files via inherent-impl-per-file; struct, ctor,
fields and load-time helpers stay in the root. fn count 123 -> 123.
- app/commands.rs (6082) -> app/commands/* grouped by command family.
The 233-arm dispatch match becomes source-ordered per-family
dispatch_* handlers, preserving first-match precedence (233 -> 233).
- app/view.rs (4686) -> app/view/{mod,overlay,modal,viewcube,controls}.
Largest file drops from 9650 to 3343. Builds clean; app launches and
loads plugins with no panic.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
2baabdf4a0
commit
bddc050b3e
39 files changed
+26674
-26113
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,299 @@
|
||||
//! `dialog` arms and helpers, split out of the original `update.rs` (#mechanical decomposition).
|
||||
|
||||
#![allow(unused_imports)]
|
||||
use super::util::*;
|
||||
use super::{format_size, tile_min_norm, TILE_EDGE_HIT_PX, VIEWCUBE_HIT_SIZE};
|
||||
use crate::app::helpers::{
|
||||
ortho_constrain, parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis,
|
||||
CoordKind,
|
||||
};
|
||||
use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS};
|
||||
use crate::modules::ModuleEvent;
|
||||
use crate::scene::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit};
|
||||
use crate::scene::model::object::GripApply;
|
||||
use crate::scene::{
|
||||
self, hover_id, CubeRegion, 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::{mouse, Point, Task};
|
||||
|
||||
|
||||
impl OpenCADStudio {
|
||||
pub(in crate::app) fn open_save_dialog_window(&mut self, tab_idx: usize) -> Task<Message> {
|
||||
// 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());
|
||||
self.active_modal = Some(crate::app::ModalKind::SaveDialog);
|
||||
Task::none()
|
||||
}
|
||||
|
||||
|
||||
pub(in crate::app) fn close_save_dialog_window(&mut self) -> Task<Message> {
|
||||
if self.active_modal == Some(crate::app::ModalKind::SaveDialog) {
|
||||
self.active_modal = None;
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
|
||||
pub(in crate::app) fn open_unsaved_dialog_window(&mut self) -> Task<Message> {
|
||||
self.active_modal = Some(crate::app::ModalKind::Unsaved);
|
||||
// The unsaved-changes prompt renders inside the main window, so bring
|
||||
// that window to the foreground — a close signal can arrive while the
|
||||
// app is backgrounded, leaving the prompt unseen behind other windows.
|
||||
// `gain_focus` alone is ignored by most Linux WMs (focus-stealing
|
||||
// prevention), so pair it with an urgency hint so the window is at
|
||||
// least flagged for attention when the compositor blocks the raise.
|
||||
match self.main_window {
|
||||
Some(id) => Task::batch([
|
||||
iced::window::gain_focus(id),
|
||||
iced::window::request_user_attention(
|
||||
id,
|
||||
Some(iced::window::UserAttention::Critical),
|
||||
),
|
||||
]),
|
||||
None => Task::none(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
pub(in crate::app) fn close_unsaved_dialog_window(&mut self) -> Task<Message> {
|
||||
if self.active_modal == Some(crate::app::ModalKind::Unsaved) {
|
||||
self.active_modal = None;
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
|
||||
pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEvent) -> Task<Message> {
|
||||
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);
|
||||
}
|
||||
ModuleEvent::PluginFileDialog {
|
||||
command,
|
||||
title,
|
||||
filter_name,
|
||||
extensions,
|
||||
} => {
|
||||
return Task::perform(
|
||||
async move {
|
||||
let exts: Vec<&str> =
|
||||
extensions.iter().map(|s| s.as_str()).collect();
|
||||
let path = rfd::AsyncFileDialog::new()
|
||||
.set_title(title)
|
||||
.add_filter(filter_name, &exts)
|
||||
.add_filter("All Files", &["*"])
|
||||
.pick_file()
|
||||
.await
|
||||
.map(|h| crate::sys::handle_path(&h));
|
||||
(command, path)
|
||||
},
|
||||
|(command, path)| Message::PluginFileDialogResult { command, path },
|
||||
);
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
pub(super) fn on_unsaved_dialog_discard(&mut self) -> Task<Message> {
|
||||
match self.pending_close.take() {
|
||||
Some(crate::app::PendingClose::Tab(idx)) => {
|
||||
let close_win = self.close_unsaved_dialog_window();
|
||||
if self.tabs.len() == 1 {
|
||||
self.tab_counter += 1;
|
||||
self.tabs[0] =
|
||||
crate::app::document::DocumentTab::new_drawing(self.tab_counter);
|
||||
self.active_tab = 0;
|
||||
self.apply_bg_default(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(crate::app::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(crate::app::PendingClose::Quit);
|
||||
} else {
|
||||
let close_win = self.close_unsaved_dialog_window();
|
||||
return Task::batch(vec![close_win, iced::exit()]);
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
pub(super) fn on_unsaved_dialog_save(&mut self) -> Task<Message> {
|
||||
match self.pending_close.take() {
|
||||
Some(crate::app::PendingClose::Tab(idx)) => {
|
||||
if let Some(path) = if cfg!(target_arch = "wasm32") { None } else { 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(crate::app::PendingClose::Tab(idx));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No path — close unsaved dialog, open custom Save As dialog.
|
||||
self.pending_close = Some(crate::app::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(crate::app::PendingClose::Quit) => {
|
||||
if let Some(idx) = self.tabs.iter().position(|t| t.dirty) {
|
||||
if let Some(path) = if cfg!(target_arch = "wasm32") { None } else { 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(crate::app::PendingClose::Quit);
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No path — close unsaved dialog, open custom Save As dialog.
|
||||
self.active_tab = idx;
|
||||
self.pending_close = Some(crate::app::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(crate::app::PendingClose::Quit);
|
||||
} else {
|
||||
let close_win = self.close_unsaved_dialog_window();
|
||||
return Task::batch(vec![close_win, iced::exit()]);
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
pub(super) fn on_unsaved_picked_save_path_some(&mut self, path: std::path::PathBuf) -> Task<Message> {
|
||||
let (_, version) = crate::io::parse_save_format(&self.save_dialog_format);
|
||||
match self.pending_close.take() {
|
||||
Some(crate::app::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(crate::app::PendingClose::Tab(idx));
|
||||
return self.open_unsaved_dialog_window();
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(crate::app::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(crate::app::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(crate::app::PendingClose::Quit);
|
||||
return self.open_unsaved_dialog_window();
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,557 @@
|
||||
//! `dynamic` arms and helpers, split out of the original `update.rs` (#mechanical decomposition).
|
||||
|
||||
#![allow(unused_imports)]
|
||||
use super::util::*;
|
||||
use super::{format_size, tile_min_norm, TILE_EDGE_HIT_PX, VIEWCUBE_HIT_SIZE};
|
||||
use crate::app::helpers::{
|
||||
ortho_constrain, parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis,
|
||||
CoordKind,
|
||||
};
|
||||
use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS};
|
||||
use crate::modules::ModuleEvent;
|
||||
use crate::scene::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit};
|
||||
use crate::scene::model::object::GripApply;
|
||||
use crate::scene::{
|
||||
self, hover_id, CubeRegion, 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::{mouse, Point, Task};
|
||||
|
||||
|
||||
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.
|
||||
pub(in crate::app) fn sync_dyn_fields(&mut self) {
|
||||
use crate::app::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;
|
||||
}
|
||||
// A command may describe its step explicitly via `dyn_spec()` — that
|
||||
// takes full control of the boxes, guide and anchor. Otherwise fall
|
||||
// back to the legacy `dyn_field()` shaping below.
|
||||
if let Some(spec) = self.tabs[i].active_cmd.as_ref().and_then(|c| c.dyn_spec()) {
|
||||
self.apply_dyn_spec(i, spec);
|
||||
return;
|
||||
}
|
||||
let field = self.tabs[i]
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
.map(|c| c.dyn_field())
|
||||
.unwrap_or(crate::command::DynField::Point);
|
||||
// A text-input step reads a single scalar. The overlay shows one box
|
||||
// the user types into (or, when the command supplies a live value,
|
||||
// sets by moving the cursor); on commit the host routes it to
|
||||
// `on_text_input` instead of `on_point`. A distance prompt keeps the
|
||||
// `Distance` box (so a perpendicular-distance live value reads
|
||||
// naturally); everything else uses the typed-only `Scalar` box.
|
||||
let wants_text = self.tabs[i]
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
.map(|c| c.wants_text_input())
|
||||
.unwrap_or(false);
|
||||
// A point step that also accepts keyword letters (PLINE A/L/C…) keeps
|
||||
// its polar boxes: only letters reach the command line, digits stay
|
||||
// coordinates. So such a step is NOT treated as a text-only prompt.
|
||||
let point_keywords = self.tabs[i]
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
.map(|c| c.point_step_accepts_keywords())
|
||||
.unwrap_or(false);
|
||||
let wants_text = wants_text && !point_keywords;
|
||||
// A step that hit-tests for an object (entity / structure pick) has no
|
||||
// coordinate to enter — clicks select, they don't place a point. Show
|
||||
// no coordinate box so the cursor stays clean and typed option keywords
|
||||
// (e.g. FILLET's "R") reach the command line instead of an X/Y field.
|
||||
let picks_object = self.tabs[i]
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
.map(|c| c.needs_entity_pick() || c.needs_structure_point_pick())
|
||||
.unwrap_or(false);
|
||||
let has_base = self.last_point.is_some();
|
||||
// While aligned to an OTRACK ray, the point step reads a single
|
||||
// distance along the ray (issue #69) — show one Distance box.
|
||||
let otrack_dist = self.otrack_active.is_some()
|
||||
&& !wants_text
|
||||
&& matches!(field, crate::command::DynField::Point);
|
||||
let default: Vec<DynComponent> = match field {
|
||||
_ if otrack_dist => vec![DynComponent::Distance],
|
||||
crate::command::DynField::Distance => vec![DynComponent::Distance],
|
||||
crate::command::DynField::Angle => vec![DynComponent::Angle],
|
||||
crate::command::DynField::Scalar => vec![DynComponent::Scalar],
|
||||
// A text prompt with the default `Point` field reads free text /
|
||||
// a name / a keyword (which may itself contain digits) from the
|
||||
// command line — show no scalar box, so digits reach the command
|
||||
// line instead of being captured into a dyn buffer.
|
||||
crate::command::DynField::Point if wants_text || picks_object => vec![],
|
||||
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 && !wants_text && !picks_object {
|
||||
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::Scalar => {
|
||||
matches!(current.as_slice(), [DynComponent::Scalar])
|
||||
}
|
||||
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;
|
||||
}
|
||||
// Derive the guide + anchor for the legacy field set so the overlay
|
||||
// draws the right construction without each command opting in.
|
||||
let comps: Vec<DynComponent> =
|
||||
self.tabs[i].dyn_fields.iter().map(|f| f.component).collect();
|
||||
self.tabs[i].dyn_guide = match comps.as_slice() {
|
||||
[DynComponent::Distance, DynComponent::Angle] | [DynComponent::Angle] => {
|
||||
crate::command::DynGuide::Polar
|
||||
}
|
||||
[DynComponent::Distance] => crate::command::DynGuide::Radius,
|
||||
_ => crate::command::DynGuide::None,
|
||||
};
|
||||
self.tabs[i].dyn_anchor = self.last_point;
|
||||
self.tabs[i].dyn_ref = None;
|
||||
}
|
||||
|
||||
/// Apply an explicit per-step [`DynSpec`](crate::command::DynSpec): rebuild
|
||||
/// the boxes from its roles (preserving typed buffers when the role set is
|
||||
/// unchanged), and set the guide + anchor.
|
||||
|
||||
pub(in crate::app) fn apply_dyn_spec(&mut self, i: usize, spec: crate::command::DynSpec) {
|
||||
use crate::app::document::DynFieldEntry;
|
||||
let new_roles: Vec<crate::command::DynRole> =
|
||||
spec.fields.iter().map(|f| f.role).collect();
|
||||
let cur_roles: Vec<crate::command::DynRole> =
|
||||
self.tabs[i].dyn_fields.iter().map(|f| f.role).collect();
|
||||
if cur_roles != new_roles {
|
||||
self.tabs[i].dyn_fields =
|
||||
spec.fields.iter().map(|f| DynFieldEntry::from_role(f.role)).collect();
|
||||
self.tabs[i].dyn_active = 0;
|
||||
}
|
||||
self.tabs[i].dyn_guide = spec.guide;
|
||||
self.tabs[i].dyn_anchor = match spec.anchor {
|
||||
crate::command::DynAnchor::LastPoint => self.last_point,
|
||||
crate::command::DynAnchor::Point(p) => Some(p.as_vec3()),
|
||||
};
|
||||
self.tabs[i].dyn_ref = spec.ref_point.map(|v| v.as_vec3());
|
||||
}
|
||||
|
||||
/// 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).
|
||||
|
||||
/// 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.
|
||||
/// Hand the active command the current UCS (as a UCS→wire affine) so
|
||||
/// axis-aligned constructions build square to the user's coordinate system.
|
||||
/// No-op for commands that don't override `set_ucs`.
|
||||
pub(in crate::app) fn push_ucs_to_cmd(&mut self, i: usize) {
|
||||
let ucs = self.tabs[i].ucs_wire_affine();
|
||||
if let Some(c) = self.tabs[i].active_cmd.as_mut() {
|
||||
c.set_ucs(ucs);
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::app) fn dyn_resolve_point(&self) -> Option<glam::Vec3> {
|
||||
use crate::app::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.tabs[i]
|
||||
.dyn_anchor
|
||||
.or(self.last_point)
|
||||
.unwrap_or(glam::Vec3::ZERO);
|
||||
// Buffer value parsed as f32 (de-scaled by the role so a typed diameter
|
||||
// becomes a radius), or the supplied geometric live value. Width/Height
|
||||
// are shown unsigned, so a typed value takes the sign of the cursor's
|
||||
// delta on that axis (`live` is the signed delta in the cartesian arms).
|
||||
let val = |idx: usize, live: f32| -> f32 {
|
||||
match fields[idx]
|
||||
.buffer
|
||||
.as_ref()
|
||||
.map(|s| s.trim().replace(',', "."))
|
||||
.and_then(|s| crate::app::expr_eval::eval_number(&s).map(|v| v as f32))
|
||||
.map(|v| v / fields[idx].role.value_scale())
|
||||
{
|
||||
Some(v) => {
|
||||
if matches!(
|
||||
fields[idx].role,
|
||||
crate::command::DynRole::Width | crate::command::DynRole::Height
|
||||
) {
|
||||
v.abs().copysign(live)
|
||||
} else {
|
||||
v
|
||||
}
|
||||
}
|
||||
None => live,
|
||||
}
|
||||
};
|
||||
// Work in the active UCS frame: the cursor delta from the base is
|
||||
// rotated into UCS, so typed cartesian/polar values are interpreted in
|
||||
// the user's coordinate system and mapped back to world on return.
|
||||
// (The delta is offset-invariant, so only the rotation matters.)
|
||||
let xf = self.tabs[i].ucs_xform();
|
||||
let d_ucs = xf.vec_to_ucs(w - base);
|
||||
let dx = d_ucs.x;
|
||||
let dy = d_ucs.y;
|
||||
let dz = d_ucs.z;
|
||||
let live_d = (dx * dx + dy * dy).sqrt();
|
||||
let live_a = dy.atan2(dx); // radians, in the UCS plane
|
||||
// A typed angle is shown unsigned (0..180); give it the sign of the
|
||||
// cursor's current side so an entry made below the X axis sweeps
|
||||
// downward to match the arc instead of mirroring up. Untyped → live.
|
||||
let angle_rad = |idx: usize| -> f32 {
|
||||
match fields[idx]
|
||||
.buffer
|
||||
.as_ref()
|
||||
.map(|s| s.trim().replace(',', "."))
|
||||
.and_then(|s| crate::app::expr_eval::eval_number(&s).map(|v| v as f32))
|
||||
{
|
||||
Some(mag) => mag.abs().to_radians().copysign(dy),
|
||||
None => live_a,
|
||||
}
|
||||
};
|
||||
let comps: Vec<DynComponent> = fields.iter().map(|f| f.component).collect();
|
||||
// Perpendicular offset: a single distance measured square to the
|
||||
// reference line (anchor → dyn_ref). The committed point lies on the
|
||||
// perpendicular through the anchor at that offset; the command projects
|
||||
// it. Untyped tracks the cursor's signed offset; typed takes the
|
||||
// cursor's side.
|
||||
if let (Some(ref_pt), [DynComponent::Distance]) =
|
||||
(self.tabs[i].dyn_ref, comps.as_slice())
|
||||
{
|
||||
let axis = (ref_pt - base).normalize_or_zero();
|
||||
let perp = glam::Vec3::new(-axis.y, axis.x, 0.0);
|
||||
let signed = (w - base).dot(perp);
|
||||
let typed = fields[0]
|
||||
.buffer
|
||||
.as_ref()
|
||||
.map(|s| s.trim().replace(',', "."))
|
||||
.and_then(|s| crate::app::expr_eval::eval_number(&s).map(|v| v as f32));
|
||||
let h = match typed {
|
||||
Some(v) => v.abs().copysign(signed),
|
||||
None => signed,
|
||||
};
|
||||
return Some(base + perp * h);
|
||||
}
|
||||
// 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();
|
||||
// Relative result: base + the typed UCS-frame offset mapped to world.
|
||||
let rel = |off_ucs: glam::Vec3| base + xf.vec_to_wcs(off_ucs);
|
||||
match comps.as_slice() {
|
||||
[DynComponent::X, DynComponent::Y] if has_base => {
|
||||
Some(rel(glam::Vec3::new(val(0, dx), val(1, dy), 0.0)))
|
||||
}
|
||||
[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(rel(glam::Vec3::new(val(0, dx), val(1, dy), val(2, dz))))
|
||||
}
|
||||
[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 = angle_rad(1);
|
||||
Some(rel(glam::Vec3::new(d * a.cos(), d * a.sin(), 0.0)))
|
||||
}
|
||||
[DynComponent::Distance] => {
|
||||
// Keep the cursor's direction (in UCS), override the magnitude.
|
||||
let dir = glam::Vec3::new(dx, dy, 0.0).normalize_or(glam::Vec3::X);
|
||||
Some(rel(dir * val(0, live_d)))
|
||||
}
|
||||
[DynComponent::Angle] => {
|
||||
// Standalone angle (e.g. ROTATE): the typed value is an
|
||||
// absolute CCW angle in the UCS plane, not a cursor-signed
|
||||
// magnitude — keep it literal. Only the polar Distance+Angle
|
||||
// pair uses the cursor-signed `angle_rad`.
|
||||
let a = val(0, live_a.to_degrees()).to_radians();
|
||||
Some(rel(glam::Vec3::new(live_d * a.cos(), live_d * a.sin(), 0.0)))
|
||||
}
|
||||
_ => 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.
|
||||
|
||||
pub(in crate::app) fn dyn_comma_advance(&mut self) {
|
||||
use crate::app::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.
|
||||
/// Give a bare typed angle the sign of the cursor's side relative to the
|
||||
/// step's reference direction, so a commit-as-text angle rotates/sweeps the
|
||||
/// way the cursor is dragging. Only applies to steps with an `Angle` field;
|
||||
/// an explicit `+`/`-` is left untouched. Returns the (possibly re-signed)
|
||||
/// text to feed `on_text_input`.
|
||||
|
||||
pub(in crate::app) fn dyn_sign_angle_text(&self, i: usize, text: String) -> String {
|
||||
let has_angle = self.tabs[i]
|
||||
.dyn_fields
|
||||
.iter()
|
||||
.any(|f| f.role == crate::command::DynRole::Angle);
|
||||
let t = text.trim();
|
||||
if !has_angle || t.is_empty() || t.starts_with('-') || t.starts_with('+') {
|
||||
return text;
|
||||
}
|
||||
if t.parse::<f32>().is_err() {
|
||||
return text;
|
||||
}
|
||||
let anchor = self.tabs[i]
|
||||
.dyn_anchor
|
||||
.or(self.last_point)
|
||||
.unwrap_or(glam::Vec3::ZERO);
|
||||
let cur = self.tabs[i].last_cursor_world;
|
||||
let a_cur = (cur.y - anchor.y).atan2(cur.x - anchor.x);
|
||||
let a_ref = self.tabs[i]
|
||||
.dyn_ref
|
||||
.map(|r| (r.y - anchor.y).atan2(r.x - anchor.x))
|
||||
.unwrap_or(0.0);
|
||||
let mut d = a_cur - a_ref;
|
||||
while d > std::f32::consts::PI {
|
||||
d -= std::f32::consts::TAU;
|
||||
}
|
||||
while d <= -std::f32::consts::PI {
|
||||
d += std::f32::consts::TAU;
|
||||
}
|
||||
if d < 0.0 {
|
||||
format!("-{t}")
|
||||
} else {
|
||||
text
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
pub(in crate::app) 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;
|
||||
}
|
||||
// OTRACK: while aligned to a tracking ray, a typed value is a distance
|
||||
// along the ray from the tracking point (issue #69).
|
||||
if let Some((base, dir)) = self.otrack_active {
|
||||
let wants_text = self.tabs[i]
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
.map(|c| c.wants_text_input())
|
||||
.unwrap_or(false);
|
||||
if !wants_text {
|
||||
if let Some(text) = self.tabs[i]
|
||||
.dyn_fields
|
||||
.iter()
|
||||
.find_map(|f| f.buffer.clone())
|
||||
{
|
||||
if let Some(dist) = crate::app::expr_eval::eval_number(text.trim()) {
|
||||
let pt = base + dir * dist as f32;
|
||||
self.last_point = Some(pt);
|
||||
for f in self.tabs[i].dyn_fields.iter_mut() {
|
||||
f.buffer = None;
|
||||
}
|
||||
self.tabs[i].dyn_active = 0;
|
||||
self.dyn_user_reshaped = false;
|
||||
self.sync_dyn_fields();
|
||||
self.reset_tracking_after_point();
|
||||
self.push_ucs_to_cmd(i);
|
||||
let result = self.tabs[i].active_cmd.as_mut().map(|c| c.on_point(pt.as_dvec3()));
|
||||
let task = result.map(|r| self.apply_cmd_result(r))?;
|
||||
self.refresh_active_cmd_preview(i);
|
||||
return Some(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// A text-input step reads its single box as a string and commits via
|
||||
// `on_text_input` (a count, radius, distance) rather than resolving a
|
||||
// point. Only the typed buffer matters here — a mouse-driven live
|
||||
// value commits through the viewport click, not Enter.
|
||||
// A point-with-keywords step (PLINE) commits a typed distance/angle as
|
||||
// a point, not as text, so it is excluded here.
|
||||
let wants_text = self.tabs[i]
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
.map(|c| {
|
||||
(c.wants_text_input() && !c.point_step_accepts_keywords())
|
||||
|| c.dyn_commit_as_text()
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if wants_text {
|
||||
let text = self.tabs[i]
|
||||
.dyn_fields
|
||||
.iter()
|
||||
.find_map(|f| f.buffer.clone())
|
||||
.unwrap_or_default();
|
||||
let text = crate::app::expr_eval::eval_to_string(text.trim());
|
||||
// Shared rule: a bare angle typed into a commit-as-text step takes
|
||||
// the sign of the cursor's side relative to the reference, so the
|
||||
// committed direction matches the drag (the box shows magnitude
|
||||
// only). Commands receive an already-signed string.
|
||||
let text = self.dyn_sign_angle_text(i, text);
|
||||
let result = self.tabs[i]
|
||||
.active_cmd
|
||||
.as_mut()
|
||||
.and_then(|c| c.on_text_input(&text));
|
||||
for f in self.tabs[i].dyn_fields.iter_mut() {
|
||||
f.buffer = None;
|
||||
}
|
||||
self.tabs[i].dyn_active = 0;
|
||||
self.sync_dyn_fields();
|
||||
let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt());
|
||||
if let Some(p) = prompt {
|
||||
self.command_line.push_info(&p);
|
||||
}
|
||||
self.refresh_active_cmd_preview(i);
|
||||
return Some(match result {
|
||||
Some(r) => self.apply_cmd_result(r),
|
||||
None => self.focus_cmd_input(),
|
||||
});
|
||||
}
|
||||
let pt = self.dyn_resolve_point()?;
|
||||
self.last_point = Some(pt);
|
||||
self.dyn_user_reshaped = false;
|
||||
self.sync_dyn_fields();
|
||||
self.reset_tracking_after_point();
|
||||
self.push_ucs_to_cmd(i);
|
||||
let result = self.tabs[i].active_cmd.as_mut().map(|c| c.on_point(pt.as_dvec3()));
|
||||
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.
|
||||
|
||||
/// 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.
|
||||
pub(in crate::app) 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.as_dvec3()))
|
||||
.unwrap_or_default();
|
||||
self.tabs[i].scene.set_preview_wires(previews);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,980 @@
|
||||
//! `file` arms and helpers, split out of the original `update.rs` (#mechanical decomposition).
|
||||
|
||||
#![allow(unused_imports)]
|
||||
use super::util::*;
|
||||
use super::{format_size, tile_min_norm, TILE_EDGE_HIT_PX, VIEWCUBE_HIT_SIZE};
|
||||
use crate::app::helpers::{
|
||||
ortho_constrain, parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis,
|
||||
CoordKind,
|
||||
};
|
||||
use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS};
|
||||
use crate::modules::ModuleEvent;
|
||||
use crate::scene::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit};
|
||||
use crate::scene::model::object::GripApply;
|
||||
use crate::scene::{
|
||||
self, hover_id, CubeRegion, 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::{mouse, Point, Task};
|
||||
|
||||
|
||||
impl OpenCADStudio {
|
||||
/// Snapshot the persisted UI preferences from live state.
|
||||
pub(in crate::app) fn current_settings(&self) -> crate::app::settings::UserSettings {
|
||||
crate::app::settings::UserSettings {
|
||||
dyn_input: self.dyn_input,
|
||||
ortho: self.ortho_mode,
|
||||
polar: self.polar_mode,
|
||||
polar_increment_deg: self.polar_increment_deg,
|
||||
snap_enabled: self.snapper.snap_enabled,
|
||||
otrack: self.snapper.otrack_enabled,
|
||||
snap_modes: crate::app::settings::UserSettings::modes_from(self.snapper.enabled.iter()),
|
||||
default_assoc_prompted: self.default_assoc_prompted,
|
||||
disabled_plugins: {
|
||||
let mut v: Vec<String> = self.disabled_plugins.iter().cloned().collect();
|
||||
v.sort();
|
||||
v
|
||||
},
|
||||
plugin_repos: self.plugin_repos.clone(),
|
||||
texteditmode: self.texteditmode,
|
||||
bg_color: self.default_bg_color.map(f4_to_u3),
|
||||
paper_bg_color: self.default_paper_bg_color.map(f4_to_u3),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply restored preferences to live state.
|
||||
pub(in crate::app) fn apply_settings(&mut self, s: &crate::app::settings::UserSettings) {
|
||||
self.dyn_input = s.dyn_input;
|
||||
self.ortho_mode = s.ortho;
|
||||
self.polar_mode = s.polar;
|
||||
self.polar_increment_deg = s.polar_increment_deg;
|
||||
self.snapper.snap_enabled = s.snap_enabled;
|
||||
self.snapper.otrack_enabled = s.otrack;
|
||||
self.snapper.enabled = s.snap_modes.iter().copied().collect();
|
||||
self.default_assoc_prompted = s.default_assoc_prompted;
|
||||
self.disabled_plugins = s.disabled_plugins.iter().cloned().collect();
|
||||
self.plugin_repos = s.plugin_repos.clone();
|
||||
self.texteditmode = s.texteditmode;
|
||||
self.default_bg_color = s.bg_color.map(u3_to_f4);
|
||||
self.default_paper_bg_color = s.paper_bg_color.map(u3_to_f4);
|
||||
// Push the restored background onto every drawing tab that exists now
|
||||
// (the start tab and any initial drawing). Tabs created later pick it
|
||||
// up via `apply_bg_default` at their construction site.
|
||||
for idx in 0..self.tabs.len() {
|
||||
self.apply_bg_default(idx);
|
||||
}
|
||||
self.rebuild_ribbon_modules();
|
||||
}
|
||||
|
||||
/// Apply the persisted default background(s) to tab `idx`. No-op for the
|
||||
/// start tab or when no default is set. Refreshes the tab's cached wires
|
||||
/// and meshes so background-adaptive colours pick up the change.
|
||||
pub(in crate::app) fn apply_bg_default(&mut self, idx: usize) {
|
||||
let bg = self.default_bg_color;
|
||||
let paper_bg = self.default_paper_bg_color;
|
||||
if bg.is_none() && paper_bg.is_none() {
|
||||
return;
|
||||
}
|
||||
let tab = &mut self.tabs[idx];
|
||||
if tab.is_start {
|
||||
return;
|
||||
}
|
||||
if let Some(c) = bg {
|
||||
tab.bg_color = Some(c);
|
||||
tab.scene.bg_color = c;
|
||||
}
|
||||
if let Some(c) = paper_bg {
|
||||
tab.paper_bg_color = Some(c);
|
||||
tab.scene.paper_bg_color = c;
|
||||
}
|
||||
tab.scene.recolor_meshes();
|
||||
tab.scene.bump_geometry();
|
||||
}
|
||||
|
||||
/// Check if a suspended command exists on the active tab and resume it
|
||||
/// with the outcome of the text editor.
|
||||
pub(in crate::app) fn post_editor_closed(&mut self, committed: bool) -> Task<Message> {
|
||||
let i = self.active_tab;
|
||||
if let Some(mut cmd) = self.tabs[i].suspended_cmd.take() {
|
||||
let res = cmd.on_editor_closed(committed);
|
||||
self.tabs[i].active_cmd = Some(cmd);
|
||||
self.apply_cmd_result(res)
|
||||
} else {
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild the ribbon's tab list from the registry, dropping the tabs of any
|
||||
/// disabled plugins. Call after `disabled_plugins` changes.
|
||||
pub(in crate::app) fn rebuild_ribbon_modules(&mut self) {
|
||||
let modules =
|
||||
crate::plugin::ribbon_modules_enabled(&self.disabled_plugins);
|
||||
self.ribbon.set_modules(modules);
|
||||
}
|
||||
|
||||
/// Snapshot of disabled plugin ids — lets the registry skip them while it
|
||||
/// holds a `&mut` borrow of the app via `HostSession`.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn disabled_plugin_ids(&self) -> rustc_hash::FxHashSet<String> {
|
||||
self.disabled_plugins.clone()
|
||||
}
|
||||
|
||||
/// Background task: fetch the curated plugin registry.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(in crate::app) fn fetch_registry_task(&self) -> Task<Message> {
|
||||
Task::perform(
|
||||
async { crate::plugin::marketplace::fetch_registry() },
|
||||
Message::PluginRegistryFetched,
|
||||
)
|
||||
}
|
||||
|
||||
/// Background task: fetch `owner/repo`'s installable release tags.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(in crate::app) fn fetch_releases_task(&self, repo: String) -> Task<Message> {
|
||||
let label = repo.clone();
|
||||
Task::perform(
|
||||
async move {
|
||||
crate::plugin::marketplace::fetch_releases(&repo).map(|rs| {
|
||||
rs.into_iter()
|
||||
.filter(|r| r.installable())
|
||||
.map(|r| r.tag)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
},
|
||||
move |res| Message::PluginReleasesFetched(label, res),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(in crate::app) fn fetch_releases_task(&self, _repo: String) -> Task<Message> {
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// Background task: download and install the `tag` release of `owner/repo`.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(in crate::app) fn install_task(&self, repo: String, tag: String) -> Task<Message> {
|
||||
Task::perform(
|
||||
async move {
|
||||
let releases = crate::plugin::marketplace::fetch_releases(&repo)?;
|
||||
let rel = releases
|
||||
.into_iter()
|
||||
.find(|r| r.tag == tag)
|
||||
.ok_or_else(|| format!("release {tag} not found"))?;
|
||||
crate::plugin::marketplace::install(&rel)
|
||||
},
|
||||
Message::PluginInstalled,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(in crate::app) fn install_task(&self, _repo: String, _tag: String) -> Task<Message> {
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// Write preferences to disk only when they differ from the last write,
|
||||
/// so a toggle persists immediately without thrashing the file.
|
||||
pub(in crate::app) fn persist_settings_if_changed(&mut self) {
|
||||
let cur = self.current_settings();
|
||||
if self.last_saved_settings.as_ref() != Some(&cur) {
|
||||
cur.save();
|
||||
self.last_saved_settings = Some(cur);
|
||||
}
|
||||
}
|
||||
|
||||
/// Record that the one-time default-association prompt has been answered and
|
||||
/// flush it to disk, so the dialog never reappears on later launches.
|
||||
pub(in crate::app) fn mark_assoc_prompted(&mut self) {
|
||||
self.default_assoc_prompted = true;
|
||||
self.persist_settings_if_changed();
|
||||
}
|
||||
|
||||
pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
||||
// Native: pick a path, then load on a worker thread. Web: the
|
||||
// browser hands back bytes, so pick + parse in one step and feed
|
||||
// the shared `FileOpened` handler directly.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
Task::perform(crate::io::pick_open_path(), Message::OpenPathPicked)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
// `FileOpened` only installs the result when an open is in
|
||||
// progress, so mark one. The browser picker + parse happen
|
||||
// inside `pick_and_load_web`; the real name is unknown until
|
||||
// then, so show a generic label meanwhile.
|
||||
self.opening = Some(crate::app::OpenProgress {
|
||||
name: "Opening…".into(),
|
||||
size_bytes: 0,
|
||||
phase: std::sync::Arc::new(std::sync::atomic::AtomicU8::new(
|
||||
crate::app::OPEN_PHASE_READING,
|
||||
)),
|
||||
started: Instant::now(),
|
||||
});
|
||||
Task::perform(crate::io::pick_and_load_web(), Message::FileOpened)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn on_file_opened(&mut self, name: String, path: std::path::PathBuf, doc: acadrust::CadDocument, caches: crate::scene::DerivedCaches) -> Task<Message> {
|
||||
// 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 = crate::app::document::DocumentTab::new_drawing(self.tab_counter);
|
||||
self.tabs.push(new_tab);
|
||||
let idx = self.tabs.len() - 1;
|
||||
self.active_tab = idx;
|
||||
self.apply_bg_default(idx);
|
||||
idx
|
||||
};
|
||||
|
||||
self.tabs[i].current_path = Some(path.clone());
|
||||
self.tabs[i].scene.document = doc;
|
||||
// Follow the file's saved current UCS from the moment it opens.
|
||||
self.tabs[i].adopt_active_ucs_from_header();
|
||||
// Route shared CJK ideographs to the language matching this
|
||||
// drawing's code page (web per-language font split). Drop the
|
||||
// glyph cache if it changed so Han re-resolves to the new
|
||||
// language's font; geometry is (re)built below regardless. (#141)
|
||||
if crate::scene::text::web_font::set_cjk_lang_from_codepage(
|
||||
&self.tabs[i].scene.document.header.code_page,
|
||||
) {
|
||||
crate::scene::text::ttf_glyph::clear_fallback_cache();
|
||||
}
|
||||
// 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.local_extent_max = caches.local_extent_max;
|
||||
self.tabs[i].scene.local_center = caches.local_center;
|
||||
self.tabs[i].scene.hatches = caches.hatches;
|
||||
self.tabs[i].scene.images = caches.images;
|
||||
self.tabs[i].scene.meshes = caches.meshes;
|
||||
self.tabs[i].scene.block_meshes = caches.block_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();
|
||||
// Seed the current table / multileader style from the file's
|
||||
// header so the ✓ marks the right one (text/dim/mline come from
|
||||
// the document header directly). DXF provides these via
|
||||
// $CTABLESTYLE / $CMLEADERSTYLE; DWG leaves them at "Standard".
|
||||
self.ribbon.active_table_style = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.header
|
||||
.current_table_style_name
|
||||
.clone();
|
||||
self.tabs[i].active_mleader_style = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.header
|
||||
.current_mleader_style_name
|
||||
.clone();
|
||||
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();
|
||||
// Load the Annotate-ribbon style dropdowns (text / dimension /
|
||||
// multileader / table) from the opened document instead of
|
||||
// leaving them on the hard-coded "Standard" default.
|
||||
self.sync_ribbon_styles();
|
||||
// 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();
|
||||
// Grid/snap are per-drawing view settings — adopt the opened
|
||||
// file's active viewport state rather than a global preference.
|
||||
self.adopt_view_display(i);
|
||||
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 = crate::app::document::HistoryState::default();
|
||||
self.refresh_selected_grips();
|
||||
Task::none()
|
||||
}
|
||||
|
||||
pub(super) fn on_wblock_save_result_some(&mut self, block_name: String, path: std::path::PathBuf) -> Task<Message> {
|
||||
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()
|
||||
}
|
||||
|
||||
pub(super) fn on_stl_export_path_some(&mut self, path: std::path::PathBuf) -> Task<Message> {
|
||||
// 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::model::mesh_model::MeshModel> = self.tabs[i]
|
||||
.scene
|
||||
.meshes
|
||||
.values()
|
||||
.filter_map(|s| s.lods.first().cloned())
|
||||
.collect();
|
||||
let mesh_refs: Vec<&crate::scene::model::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()
|
||||
}
|
||||
|
||||
pub(super) fn on_step_export_path_some(&mut self, path: std::path::PathBuf) -> Task<Message> {
|
||||
let i = self.active_tab;
|
||||
// Export uses LOD 0 (full resolution); see StlExportPath above.
|
||||
let meshes: Vec<crate::scene::model::mesh_model::MeshModel> = self.tabs[i]
|
||||
.scene
|
||||
.meshes
|
||||
.values()
|
||||
.filter_map(|s| s.lods.first().cloned())
|
||||
.collect();
|
||||
let mesh_refs: Vec<&crate::scene::model::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()
|
||||
}
|
||||
|
||||
pub(super) fn on_obj_import_path_some(&mut self, path: std::path::PathBuf) -> Task<Message> {
|
||||
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()
|
||||
}
|
||||
|
||||
pub(super) fn on_save_file(&mut self) -> Task<Message> {
|
||||
if self.read_only {
|
||||
self.command_line
|
||||
.push_error("Read-only session (--read-only): saving is disabled.");
|
||||
return Task::none();
|
||||
}
|
||||
let i = self.active_tab;
|
||||
// Stamp the live grid/snap toggles onto the VPort so the file
|
||||
// reflects them even if they came from settings with no
|
||||
// in-session toggle (#121).
|
||||
self.sync_vport_display(i);
|
||||
// Native: save straight to the known path. Web has no path
|
||||
// (downloads instead), so always go through the Save dialog.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(path) = self.tabs[i].current_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}")),
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
self.save_dialog_for_unsaved = false;
|
||||
self.open_save_dialog_window(i)
|
||||
}
|
||||
|
||||
pub(super) fn on_save_dialog_confirm(&mut self) -> Task<Message> {
|
||||
let (ext, version) = crate::io::parse_save_format(&self.save_dialog_format);
|
||||
// The user need not type an extension: append the selected
|
||||
// format's one when the entered name carries none.
|
||||
let name = self.save_dialog_filename.trim();
|
||||
let filename = if name.is_empty() {
|
||||
format!("drawing.{ext}")
|
||||
} else if std::path::Path::new(name).extension().is_none() {
|
||||
format!("{name}.{ext}")
|
||||
} else {
|
||||
name.to_string()
|
||||
};
|
||||
self.save_dialog_filename = filename.clone();
|
||||
self.save_dialog_filename = filename.clone();
|
||||
let close = self.close_save_dialog_window();
|
||||
let i = self.active_tab;
|
||||
sync_annotation_scale_header(&mut self.tabs[i].scene);
|
||||
|
||||
// Native: write to the chosen path. Web: download the bytes
|
||||
// under the chosen name (no filesystem).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let path = self.save_dialog_folder.join(&filename);
|
||||
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
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
match crate::io::save_to_bytes(&self.tabs[i].scene.document, ext, version) {
|
||||
Ok(bytes) => {
|
||||
crate::sys::download_bytes(&filename, &bytes);
|
||||
self.tabs[i].dirty = false;
|
||||
self.command_line.push_output(&format!("Saved: {filename}"));
|
||||
}
|
||||
Err(e) => self.command_line.push_error(&format!("Save failed: {e}")),
|
||||
}
|
||||
// Continue a pending tab close.
|
||||
if self.save_dialog_for_unsaved {
|
||||
if let Some(crate::app::PendingClose::Tab(idx)) = self.pending_close.take() {
|
||||
let cont = self.update(Message::TabClose(idx));
|
||||
return Task::batch([close, cont]);
|
||||
}
|
||||
}
|
||||
close
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn on_page_setup_commit(&mut self) -> Task<Message> {
|
||||
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 AND its embedded
|
||||
// PlotSettings fields. `paper_limits()` (sheet rendering) and
|
||||
// the DWG writer both read these from the Layout, so a page
|
||||
// setup that only touched a side PlotSettings object would not
|
||||
// reflect on screen or survive a save. The dialog's w/h are
|
||||
// the final sheet dimensions, so store them verbatim with no
|
||||
// further rotation swap (#156).
|
||||
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);
|
||||
l.paper_width = w;
|
||||
l.paper_height = h;
|
||||
l.plot_rotation = 0;
|
||||
l.plot_paper_units = 1; // millimetres
|
||||
l.plot_origin_x = offset_x;
|
||||
l.plot_origin_y = offset_y;
|
||||
// Custom dimensions no longer match a named size.
|
||||
l.paper_size = String::new();
|
||||
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;
|
||||
// The paper sheet fill is cached; bump geometry so the new
|
||||
// sheet size re-tessellates and shows immediately.
|
||||
self.tabs[i].scene.bump_geometry();
|
||||
self.command_line.push_info(&format!(
|
||||
"Page setup: {w:.1}×{h:.1} mm area={plot_area} \
|
||||
center={center} rot={rotation}°"
|
||||
));
|
||||
}
|
||||
self.active_modal = None;
|
||||
Task::none()
|
||||
}
|
||||
|
||||
pub(super) fn on_plot_export_path_some(&mut self, path: std::path::PathBuf) -> Task<Message> {
|
||||
let i = self.active_tab;
|
||||
let scene = &self.tabs[i].scene;
|
||||
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::PlotType;
|
||||
let ps_snap = scene.effective_plot_settings();
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
pub(super) fn on_print_to_printer(&mut self) -> Task<Message> {
|
||||
let i = self.active_tab;
|
||||
let scene = &self.tabs[i].scene;
|
||||
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::PlotType;
|
||||
let ps_snap = scene.effective_plot_settings();
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn on_plot_style_panel_apply(&mut self) -> Task<Message> {
|
||||
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()
|
||||
}
|
||||
|
||||
pub(super) fn on_plot_style_panel_save(&mut self) -> Task<Message> {
|
||||
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| crate::sys::handle_path(&h))
|
||||
},
|
||||
Message::PlotStylePanelSavePath,
|
||||
)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,71 @@
|
||||
//! Small pure helpers split out of `update.rs`.
|
||||
|
||||
use crate::scene::Scene;
|
||||
|
||||
/// 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).
|
||||
pub(super) 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()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) 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)
|
||||
}
|
||||
|
||||
/// Convert an internal `[r,g,b,a]` colour (0.0–1.0) to a persisted 0–255 RGB
|
||||
/// triplet, dropping alpha (backgrounds are always opaque).
|
||||
pub(super) fn f4_to_u3([r, g, b, _]: [f32; 4]) -> [u8; 3] {
|
||||
[
|
||||
(r * 255.0).round().clamp(0.0, 255.0) as u8,
|
||||
(g * 255.0).round().clamp(0.0, 255.0) as u8,
|
||||
(b * 255.0).round().clamp(0.0, 255.0) as u8,
|
||||
]
|
||||
}
|
||||
|
||||
/// Inverse of [`f4_to_u3`]: a persisted 0–255 RGB triplet back to an opaque
|
||||
/// `[r,g,b,a]` colour.
|
||||
pub(super) fn u3_to_f4([r, g, b]: [u8; 3]) -> [f32; 4] {
|
||||
[r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0]
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user