Files
OneCAD-Draft/src/app/document.rs
T
Hakan SevenandClaude Opus 5.5 1cbcc8f4c5 fix: feet-inch dynamic input, STRETCH preselection, ribbon title wrap
- Dynamic-input fields take ' and " and read 2'9" / 3' as lengths (#937)
- STRETCH on a selection made by a crossing window stretches through that
  window and goes straight to the base point (#1068)
- STRETCH's crossing-window steps show no coordinate boxes (#657)
- Collapsed ribbon panel titles wrap at words, never mid-word (#920)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:15:35 +03:00

905 lines
38 KiB
Rust

use crate::command::CadCommand;
use crate::modules::draw::modify::block_edit::BlockEditSession;
use crate::modules::draw::modify::refedit::RefEditSession;
use crate::scene::pick::grip::GripEdit;
use crate::scene::GripDef;
use crate::scene::{ObjectIsolationState, Scene};
use crate::snap::SnapResult;
use crate::t;
use crate::ui::{LayerPanel, PropertiesPanel};
use codec::tables::{normalize_name, Ucs};
use codec::{CadDocument, EntityType, Handle};
use iced;
use std::any::Any;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
static NEXT_DOCUMENT_TAB_ID: AtomicU64 = AtomicU64::new(1);
// ── Dynamic input ──────────────────────────────────────────────────────────
/// One quantity shown in the dynamic-input overlay near the cursor.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(super) enum DynComponent {
/// Absolute X ordinate.
X,
/// Absolute Y ordinate.
Y,
/// Absolute Z ordinate (only visible after the user types a second
/// `,` separator from a cartesian X/Y configuration).
Z,
/// Linear distance from the last point.
Distance,
/// Angle from the last point, displayed and entered in the drawing's units.
Angle,
/// A scalar the command reads from the command line (a count, a radius,
/// a delta). Typed-only — it has no geometric live value derived from
/// the cursor unless the command supplies one via `dyn_live_value`.
Scalar,
}
/// A single editable dynamic-input field. `buffer == None` means the box
/// tracks the cursor live; once the user types, the typed text is held in
/// `buffer` and the box stops following the cursor (it is "locked").
#[derive(Clone, Debug)]
pub(super) struct DynFieldEntry {
pub(super) component: DynComponent,
/// Semantic role — drives the label and value scaling (e.g. diameter).
/// Defaults to the role matching `component` on the legacy path.
pub(super) role: crate::command::DynRole,
pub(super) buffer: Option<String>,
}
impl DynFieldEntry {
pub(super) fn new(component: DynComponent) -> Self {
Self {
component,
role: default_role_for(component),
buffer: None,
}
}
/// Build from an explicit role (spec-driven path); the resolution
/// component is derived from the role.
pub(super) fn from_role(role: crate::command::DynRole) -> Self {
Self {
component: component_for_role(role),
role,
buffer: None,
}
}
pub(super) fn locked(&self) -> bool {
self.buffer.is_some()
}
}
/// Map a [`DynRole`](crate::command::DynRole) to the ordinate/distance/angle
/// component used by point resolution.
pub(super) fn component_for_role(role: crate::command::DynRole) -> DynComponent {
use crate::command::DynRole;
match role {
DynRole::X | DynRole::Width => DynComponent::X,
DynRole::Y | DynRole::Height => DynComponent::Y,
DynRole::Z => DynComponent::Z,
DynRole::Distance | DynRole::Radius | DynRole::Diameter => DynComponent::Distance,
DynRole::Angle => DynComponent::Angle,
DynRole::Factor | DynRole::Count => DynComponent::Scalar,
}
}
fn default_role_for(component: DynComponent) -> crate::command::DynRole {
use crate::command::DynRole;
match component {
DynComponent::X => DynRole::X,
DynComponent::Y => DynRole::Y,
DynComponent::Z => DynRole::Z,
DynComponent::Distance => DynRole::Distance,
DynComponent::Angle => DynRole::Angle,
DynComponent::Scalar => DynRole::Factor,
}
}
/// An open sketch — the state CREATESKETCH parks so FINISHSKETCH can put it
/// back.
///
/// A sketch here is a *mode*, not a document object: the drawing plane, a
/// square-on view, and snapping forced live. The geometry it produces is
/// ordinary model-space entities, which is what keeps the result a normal
/// DWG that other software can open. `opened_with` is the handle set at the
/// moment the sketch opened, so the difference on finish is exactly what the
/// user drew — that difference is what gets offered to EXTRUDE.
pub(super) struct SketchSession {
/// Display name, e.g. `"Sketch1"`.
pub(super) name: String,
/// UCS active before the sketch opened. Restored on finish.
pub(super) previous_ucs: Option<Ucs>,
/// Snap master state before the sketch forced it on, so a user who works
/// with snapping off gets that back rather than silently keeping it.
pub(super) previous_snap_enabled: bool,
/// Entity handles already present when the sketch opened.
pub(super) opened_with: std::collections::HashSet<codec::Handle>,
}
// ── Per-document tab state ─────────────────────────────────────────────────
pub(super) struct DocumentTab {
/// Stable identity across tab insert/remove operations. Background work
/// must never target a tab by its transient vector index.
pub(super) id: u64,
pub(super) scene: Scene,
pub(super) current_path: Option<PathBuf>,
/// Persistent native edit lease for the drawing currently at
/// `current_path`. The sidecar portion survives atomic file replacement.
#[cfg(not(target_arch = "wasm32"))]
pub(super) edit_lease: Option<crate::io::edit_lock::EditLease>,
/// True when another editor owns the drawing lease. Direct Save stays
/// blocked until Retry acquires it or the user chooses Save As.
#[cfg(not(target_arch = "wasm32"))]
pub(super) edit_lock_conflict: bool,
/// Disk state observed after open or the latest successful save.
#[cfg(not(target_arch = "wasm32"))]
pub(super) disk_fingerprint: Option<crate::io::edit_lock::FileFingerprint>,
pub(super) dirty: bool,
/// Direct Save is redirected to Save As after open-time repairs so the
/// source drawing cannot be overwritten accidentally.
pub(super) recovery_save_as_required: bool,
/// Monotonic committed-edit/undo/redo revision. Background save completion
/// uses it with scene epochs so an older snapshot never clears newer work.
pub(super) edit_revision: u64,
pub(super) tab_title: String,
pub(super) properties: PropertiesPanel,
pub(super) layers: LayerPanel,
pub(super) active_cmd: Option<Box<dyn CadCommand>>,
/// Remaining command tokens queued behind a PAUSE (or `\`) awaiting user interaction.
pub(super) pending_pause_tokens: Option<Vec<String>>,
/// The selection set the most recent command worked on, captured when a
/// finishing command drops the live selection — re-selectable with the
/// "Previous" keyword at any Select objects prompt (#426).
pub(super) prev_selection: Vec<codec::Handle>,
pub(super) last_cmd: Option<String>,
/// Most recently created path drawable. A fresh LINE/PLINE can accept its
/// current final endpoint with Enter before the first click.
pub(super) last_draw_anchor: Option<Handle>,
pub(super) snap_result: Option<SnapResult>,
pub(super) active_grip: Option<GripEdit>,
pub(super) selected_grips: Vec<GripDef>,
/// Entity handle for each entry in `selected_grips`.
pub(super) selected_grip_handles: Vec<Handle>,
/// Shift-selected grips, keyed by entity and object-local grip id.
pub(super) hot_grips: rustc_hash::FxHashSet<(Handle, usize)>,
/// Grip-mode "Copy" toggle (context menu): each grip placement leaves the
/// original in place and adds a modified copy, until Enter / Esc.
pub(super) grip_copy: bool,
/// Grip-mode "Base Point" (context menu): the next left-click re-bases
/// the active grip edit instead of committing it.
pub(super) grip_base_pending: bool,
pub(super) selected_handle: Option<Handle>,
/// Dynamic-block visibility grip for the current single selection.
pub(super) visibility_grip: Option<super::visibility::VisibilityGrip>,
pub(super) wireframe: bool,
pub(super) render_mode: codec::entities::ViewportRenderMode,
pub(super) visual_style: String,
pub(super) last_cursor_world: glam::DVec3,
pub(super) last_cursor_screen: iced::Point,
/// Base point (`App::last_point`) projected to viewport pixels, refreshed
/// on cursor move. Lets the dynamic-input overlay place the distance label
/// along the rubber-band line and the angle label at its end.
pub(super) last_point_screen: Option<iced::Point>,
/// Dynamic-input fields shown near the cursor while a command waits
/// for a point/distance/angle. Rebuilt whenever the active command's
/// `dyn_field()` or the presence of a base point changes. Empty when
/// dynamic input is not active.
pub(super) dyn_fields: Vec<DynFieldEntry>,
/// Guide geometry the overlay draws for the current step (set alongside
/// `dyn_fields`). Polar arc, radius line, axis-delta projections, etc.
pub(super) dyn_guide: crate::command::DynGuide,
/// World-space anchor the current step's values are measured from. `None`
/// falls back to `App::last_point`.
pub(super) dyn_anchor: Option<glam::DVec3>,
/// Far end of a reference line through `dyn_anchor` (for the `Perp` guide:
/// the base edge / major axis the offset is measured square to).
pub(super) dyn_ref: Option<glam::DVec3>,
/// `dyn_ref` projected to viewport pixels.
pub(super) dyn_ref_screen: Option<iced::Point>,
/// Index of the field that TAB has focused (the one keystrokes edit).
pub(super) dyn_active: usize,
pub(super) history: HistoryState,
pub(super) active_layer: String,
/// Node graph shown by the command line's graph button.
pub(super) graph: crate::ui::node_graph::Graph,
/// Currently active UCS. `None` means WCS (identity transform).
pub(super) active_ucs: Option<Ucs>,
/// Open sketch, if CREATESKETCH is in effect. `None` is the normal
/// direct-modelling state.
pub(super) sketch_session: Option<SketchSession>,
/// Sketches opened in this tab so far, so each gets a distinct name.
pub(super) sketch_count: u32,
/// Custom model-space background color. `None` = default dark grey.
pub(super) bg_color: Option<[f32; 4]>,
/// Custom paper-space background color. `None` = default off-white grey.
pub(super) paper_bg_color: Option<[f32; 4]>,
/// Active REFEDIT session, if any.
pub(super) refedit_session: Option<RefEditSession>,
/// COUNT mode, while it is on.
pub(crate) count: Option<crate::ui::window::count_palette::CountMode>,
/// Open BEDIT block tabs. Definitions are edited live; each tab owns its
/// entry snapshot and camera so nested blocks can remain open independently.
pub(super) block_edits: Vec<BlockEditSession>,
/// Index of the block tab currently shown, or `None` for Model/Paper space.
pub(super) active_block_edit: Option<usize>,
/// Currently active MLeader style name.
pub(super) active_mleader_style: String,
/// Last camera_generation value written back to the document.
pub(super) last_synced_camera_gen: u64,
/// Session set of unloaded reference keys (Task 8b). Owns the set that
/// `collect_entries` takes as `unloaded`, so CLI and palette agree.
pub(super) xref_unloaded: crate::io::xref_model::UnloadSet,
/// References attached with a relative path while the drawing had no
/// file yet: stored full until the first save, then made relative.
pub(super) xref_relative_on_save: rustc_hash::FxHashSet<String>,
/// Load-time mtimes per reference key (Task 8b). Written on every
/// palette refresh; `Stale` is detectable from the second refresh on.
pub(super) xref_stat_cache: crate::io::xref_model::RefStatCache,
/// NotFound count from the last file-open xref resolution (Task 8b).
/// The palette renders a neutral "open EXTERNALREFERENCES" notice while non-zero;
/// never auto-opens a modal. Cleared by a clean palette refresh.
pub(super) xref_missing: usize,
/// Sentinel "Welcome / Start" tab. Always at index 0 when present.
/// Cannot be closed; the viewport area renders a welcome page instead
/// of the model-space shader. The scene is still constructed so the
/// rest of the code can treat it as a normal tab when reading.
pub(super) is_start: bool,
/// Interactive PAN mode (the PAN command / tool). While active, a left-
/// button drag pans the view instead of selecting — the only pan path on a
/// device with no middle mouse button (a trackpad / web client). Exited
/// with Esc or by starting another command.
pub(super) pan_mode: bool,
/// Interactive 3-D orbit mode. While active, a left-button drag follows
/// the same camera-orbit path as Shift + middle-button drag.
pub(super) orbit_mode: bool,
/// Interactive ZOOM Dynamic mode. A left drag pans horizontally and zooms
/// vertically until Escape or another command ends the mode.
pub(super) zoom_dynamic_mode: bool,
/// Per-plugin document state (`plugin::BuiltinPlugin` manifest id → state).
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
pub(super) plugin_state: HashMap<&'static str, Box<dyn Any + Send + Sync>>,
pub(super) suspended_cmd: Option<Box<dyn CadCommand>>,
/// `suspended_cmd` was parked by a transparent command (`'ZOOM`) and is
/// restored as soon as the transparent one ends.
pub(super) transparent_resume: bool,
/// Model-space corners of the last crossing window drawn with no command
/// running: STRETCH on that selection stretches through it (#1068).
pub(super) last_crossing_window: Option<([f64; 2], [f64; 2])>,
}
impl DocumentTab {
pub(super) fn rename_layer(&mut self, old_name: &str, new_name: &str) -> bool {
let active = normalize_name(&self.active_layer) == normalize_name(old_name);
if !self.scene.rename_layer(old_name, new_name) {
return false;
}
if active {
self.active_layer = new_name.to_string();
}
self.dirty = true;
true
}
pub(super) fn active_block_edit_session(&self) -> Option<&BlockEditSession> {
self.active_block_edit
.and_then(|index| self.block_edits.get(index))
}
pub(super) fn active_block_edit_session_mut(&mut self) -> Option<&mut BlockEditSession> {
self.active_block_edit
.and_then(|index| self.block_edits.get_mut(index))
}
/// The [`crate::scene::parametric_constraints::ParametricScope`] a new persistent
/// constraint should attach to right now — whatever's actually being
/// edited: the open block definition if a BEDIT session is active, model
/// space otherwise.
pub(super) fn current_parametric_scope(
&self,
) -> crate::scene::parametric_constraints::ParametricScope {
match self.active_block_edit_session() {
Some(session) => {
crate::scene::parametric_constraints::ParametricScope::Block(session.br_handle)
}
None => crate::scene::parametric_constraints::ParametricScope::ModelSpace,
}
}
/// The active WCS↔UCS converter for this tab — identity when no UCS is set.
/// Every consumer that needs UCS-relative coordinates goes through this.
pub(super) fn ucs_xform(&self) -> super::helpers::UcsXform {
super::helpers::UcsXform::from_active(self.active_ucs.as_ref())
}
/// World-space UCS origin in full f64 precision (stored f64 in the header /
/// viewport). Used to anchor the UCS icon and as the cursor-pick plane point
/// — `UcsXform`'s origin is f32 and would quantize the anchor at UTM scale.
pub(super) fn ucs_origin_world(&self) -> glam::DVec3 {
self.active_ucs
.as_ref()
.map(|u| glam::dvec3(u.origin.x, u.origin.y, u.origin.z))
.unwrap_or(glam::DVec3::ZERO)
}
/// True when the active pane has a drawing coordinate plane.
pub(super) fn editing_model_space(&self) -> bool {
self.scene.current_layout == "Model"
|| self.scene.active_viewport.is_some()
|| self.active_ucs.is_some()
}
/// The document's saved model-space UCS (header), as a `Ucs`. `None` when it
/// is identity (plain WCS).
pub(super) fn model_ucs_from_header(&self) -> Option<Ucs> {
let h = &self.scene.document.header;
let mut u = if h.model_space_ucs_name.is_empty() {
Ucs::new("*ACTIVE*")
} else {
self.scene
.document
.ucss
.get(&h.model_space_ucs_name)
.cloned()
.unwrap_or_else(|| Ucs::new(h.model_space_ucs_name.clone()))
};
u.origin = h.model_space_ucs_origin;
u.x_axis = h.model_space_ucs_x_axis;
u.y_axis = h.model_space_ucs_y_axis;
if h.model_space_ucs_name.is_empty() && super::helpers::UcsXform::from_ucs(&u).is_identity()
{
None
} else {
Some(u)
}
}
/// A floating viewport's own per-viewport UCS, if it has one set. `None`
/// when the viewport uses world coordinates or the handle is not a viewport.
pub(super) fn ucs_from_viewport(&self, h: Handle) -> Option<Ucs> {
let vp = match self.scene.document.get_entity(h) {
Some(codec::EntityType::Viewport(vp)) => vp,
_ => return None,
};
if !vp.ucs_per_viewport {
return None;
}
let mut u = if vp.ucs_handle.is_null() {
Ucs::new("*VPUCS*")
} else {
self.scene
.document
.ucss
.iter()
.find(|ucs| ucs.handle == vp.ucs_handle)
.cloned()
.unwrap_or_else(|| Ucs::new("*VPUCS*"))
};
u.origin = vp.ucs_origin;
u.x_axis = vp.ucs_x_axis;
u.y_axis = vp.ucs_y_axis;
if vp.ucs_handle.is_null() && super::helpers::UcsXform::from_ucs(&u).is_identity() {
None
} else {
Some(u)
}
}
pub(super) fn ucs_from_layout(&self) -> Option<Ucs> {
let layout = self.scene.document.objects.values().find_map(|object| {
let codec::objects::ObjectType::Layout(layout) = object else {
return None;
};
(layout.name == self.scene.current_layout).then_some(layout)
})?;
let mut ucs = self
.scene
.document
.ucss
.iter()
.find(|ucs| ucs.handle == layout.named_ucs)
.cloned()
.unwrap_or_else(|| Ucs::new("*PAPERUCS*"));
ucs.origin = codec::types::Vector3::new(
layout.ucs_origin.0,
layout.ucs_origin.1,
layout.ucs_origin.2,
);
ucs.x_axis = codec::types::Vector3::new(
layout.ucs_x_axis.0,
layout.ucs_x_axis.1,
layout.ucs_x_axis.2,
);
ucs.y_axis = codec::types::Vector3::new(
layout.ucs_y_axis.0,
layout.ucs_y_axis.1,
layout.ucs_y_axis.2,
);
ucs.elevation = layout.elevation;
ucs.ortho_type = layout.ucs_ortho_type;
ucs.named_ucs_handle = layout.named_ucs;
ucs.base_ucs_handle = layout.base_ucs;
let is_world = layout.named_ucs.is_null()
&& layout.base_ucs.is_null()
&& layout.ucs_ortho_type == 0
&& layout.elevation.abs() <= f64::EPSILON
&& super::helpers::UcsXform::from_ucs(&ucs).is_identity();
(!is_world).then_some(ucs)
}
/// Set `active_ucs` to the UCS of the *current pane*: the entered viewport's
/// own per-viewport UCS, the model header UCS, or the layout UCS. Keeps the
/// ViewCube in lock-step. Call on every pane
/// change (enter/exit viewport, layout / tab switch, load) so one field
/// drives all UCS-aware systems regardless of where editing happens.
pub(super) fn refresh_active_ucs(&mut self) {
self.active_ucs = if let Some(session) = self.active_block_edit_session() {
session.editor_ucs.clone()
} else if let Some(h) = self.scene.active_viewport {
self.ucs_from_viewport(h)
} else if self.scene.current_layout == "Model" {
self.model_ucs_from_header()
} else {
self.ucs_from_layout()
};
if self.active_block_edit.is_none()
&& self.scene.active_viewport.is_none()
&& self.scene.current_layout != "Model"
{
self.sync_paper_ucs_header();
}
self.sync_ucs_to_scene();
}
fn sync_paper_ucs_header(&mut self) {
use codec::types::Vector3;
let header = &mut self.scene.document.header;
match &self.active_ucs {
Some(ucs) => {
header.paper_space_ucs_origin = ucs.origin;
header.paper_space_ucs_x_axis = ucs.x_axis;
header.paper_space_ucs_y_axis = ucs.y_axis;
header.paper_elevation = ucs.elevation;
header.paper_space_ucs_name = if (ucs.named_ucs_handle.is_valid()
|| ucs.handle.is_valid())
&& !ucs.name.starts_with('*')
{
ucs.name.clone()
} else {
String::new()
};
header.paper_ucs_ortho_ref = ucs.base_ucs_handle;
header.paper_ucs_ortho_view = ucs.ortho_type;
}
None => {
header.paper_space_ucs_origin = Vector3::ZERO;
header.paper_space_ucs_x_axis = Vector3::UNIT_X;
header.paper_space_ucs_y_axis = Vector3::UNIT_Y;
header.paper_elevation = 0.0;
header.paper_space_ucs_name.clear();
header.paper_ucs_ortho_ref = Handle::NULL;
header.paper_ucs_ortho_view = 0;
}
}
}
/// Persist `active_ucs` back to its pane's storage so it round-trips: the
/// entered viewport's per-viewport UCS fields, or the document header's
/// model-space UCS in the Model tab, or the layout UCS. Call after a change.
pub(super) fn persist_active_ucs(&mut self) {
use codec::types::Vector3;
if let Some(index) = self.active_block_edit {
let active_ucs = self.active_ucs.clone();
if let Some(session) = self.block_edits.get_mut(index) {
session.editor_ucs = active_ucs;
}
} else if let Some(h) = self.scene.active_viewport {
let (o, x, y, handle, per) = match &self.active_ucs {
Some(u) => (u.origin, u.x_axis, u.y_axis, u.handle, true),
None => (
Vector3::ZERO,
Vector3::UNIT_X,
Vector3::UNIT_Y,
Handle::NULL,
false,
),
};
if let Some(codec::EntityType::Viewport(vp)) = self.scene.document.get_entity_mut(h)
{
vp.ucs_origin = o;
vp.ucs_x_axis = x;
vp.ucs_y_axis = y;
vp.ucs_handle = handle;
vp.ucs_per_viewport = per;
}
} else if self.scene.current_layout == "Model" {
let h = &mut self.scene.document.header;
match &self.active_ucs {
Some(u) => {
h.model_space_ucs_origin = u.origin;
h.model_space_ucs_x_axis = u.x_axis;
h.model_space_ucs_y_axis = u.y_axis;
h.model_space_ucs_name = u.name.clone();
}
None => {
h.model_space_ucs_origin = Vector3::ZERO;
h.model_space_ucs_x_axis = Vector3::UNIT_X;
h.model_space_ucs_y_axis = Vector3::UNIT_Y;
h.model_space_ucs_name.clear();
}
}
} else {
let (origin, x_axis, y_axis, elevation, ortho, named, base) = match &self.active_ucs {
Some(ucs) => (
ucs.origin,
ucs.x_axis,
ucs.y_axis,
ucs.elevation,
ucs.ortho_type,
if ucs.named_ucs_handle.is_valid() {
ucs.named_ucs_handle
} else {
ucs.handle
},
ucs.base_ucs_handle,
),
None => (
Vector3::ZERO,
Vector3::UNIT_X,
Vector3::UNIT_Y,
0.0,
0,
Handle::NULL,
Handle::NULL,
),
};
for object in self.scene.document.objects.values_mut() {
let codec::objects::ObjectType::Layout(layout) = object else {
continue;
};
if layout.name == self.scene.current_layout {
layout.ucs_origin = (origin.x, origin.y, origin.z);
layout.ucs_x_axis = (x_axis.x, x_axis.y, x_axis.z);
layout.ucs_y_axis = (y_axis.x, y_axis.y, y_axis.z);
layout.elevation = elevation;
layout.ucs_ortho_type = ortho;
layout.named_ucs = named;
layout.base_ucs = base;
break;
}
}
self.sync_paper_ucs_header();
}
}
/// Adopt the active pane's UCS on load (the file's saved model-space UCS in
/// the Model tab). Thin wrapper over [`refresh_active_ucs`] kept for the
/// load call sites.
pub(super) fn adopt_active_ucs_from_header(&mut self) {
self.refresh_active_ucs();
}
/// Push the active UCS rotation into the scene so the ViewCube composes with
/// it. Call after any change to `active_ucs`.
pub(super) fn sync_ucs_to_scene(&mut self) {
self.scene.viewcube_ucs = self.ucs_xform().rotation_mat();
}
/// Grid origin (render/wire space) and UCS→world rotation for grid snap and
/// the grid overlay. Identity / origin-at-zero outside model space.
pub(super) fn ucs_grid_basis(&self) -> (glam::Vec3, glam::Mat4) {
if !self.editing_model_space() {
return (glam::Vec3::ZERO, glam::Mat4::IDENTITY);
}
let xf = self.ucs_xform();
let (o, ..) = xf.axes();
let origin = glam::Vec3::new(o.x as f32, o.y as f32, o.z as f32);
(origin, xf.rotation_mat())
}
pub(super) fn new_drawing(n: usize) -> Self {
let mut scene = Scene::new();
scene.populate_new_drawing_defaults();
Self {
id: NEXT_DOCUMENT_TAB_ID.fetch_add(1, Ordering::Relaxed),
scene,
current_path: None,
#[cfg(not(target_arch = "wasm32"))]
edit_lease: None,
#[cfg(not(target_arch = "wasm32"))]
edit_lock_conflict: false,
#[cfg(not(target_arch = "wasm32"))]
disk_fingerprint: None,
dirty: false,
recovery_save_as_required: false,
edit_revision: 0,
prev_selection: Vec::new(),
tab_title: format!("Drawing{}", n),
properties: PropertiesPanel::empty(),
layers: LayerPanel::default(),
active_cmd: None,
pending_pause_tokens: None,
last_cmd: None,
last_draw_anchor: None,
snap_result: None,
active_grip: None,
selected_grips: vec![],
selected_grip_handles: vec![],
hot_grips: rustc_hash::FxHashSet::default(),
grip_copy: false,
grip_base_pending: false,
selected_handle: None,
visibility_grip: None,
wireframe: false,
render_mode: codec::entities::ViewportRenderMode::Wireframe2D,
visual_style: "Wireframe 2D".into(),
last_cursor_world: glam::DVec3::ZERO,
last_cursor_screen: iced::Point::ORIGIN,
last_point_screen: None,
dyn_fields: Vec::new(),
dyn_guide: crate::command::DynGuide::Polar,
dyn_anchor: None,
dyn_ref: None,
dyn_ref_screen: None,
dyn_active: 0,
history: HistoryState::default(),
active_layer: "0".to_string(),
graph: Default::default(),
active_ucs: None,
sketch_session: None,
sketch_count: 0,
bg_color: None,
paper_bg_color: None,
refedit_session: None,
count: None,
block_edits: Vec::new(),
active_block_edit: None,
active_mleader_style: "Standard".to_string(),
last_synced_camera_gen: 0,
xref_unloaded: crate::io::xref_model::UnloadSet::default(),
xref_relative_on_save: rustc_hash::FxHashSet::default(),
xref_stat_cache: crate::io::xref_model::RefStatCache::default(),
xref_missing: 0,
is_start: false,
pan_mode: false,
orbit_mode: false,
zoom_dynamic_mode: false,
plugin_state: HashMap::new(),
suspended_cmd: None,
transparent_resume: false,
last_crossing_window: None,
}
}
/// Welcome / Start tab. Carries a dummy Scene so the rest of the app
/// can read tab state uniformly; the viewport renderer detects
/// `is_start` and shows a welcome page instead.
pub(super) fn new_start() -> Self {
let mut t = Self::new_drawing(0);
t.tab_title = "Start".to_string();
t.is_start = true;
t
}
pub(super) fn tab_display_name(&self) -> String {
match &self.current_path {
Some(p) => p
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string(),
None => {
if self.is_start {
t!("Start").into_owned()
} else {
self.tab_title.clone()
}
}
}
}
}
/// One undo/redo entry. Every edit is represented as a first-touch entity delta
/// plus an optional structure-only document image, so history never clones the
/// full entity store.
#[derive(Clone)]
pub(super) enum HistorySnapshot {
// Boxed: `DeltaSnapshot` grew past the other variants once
// `parametric_constraints` (ERASE-undo fix) was added, and every undo/redo
// stack slot costs as much as this enum's largest variant regardless of
// which one it actually holds.
Delta(Box<DeltaSnapshot>),
ObjectVisibility(ObjectVisibilitySnapshot),
}
impl HistorySnapshot {
pub(super) fn label(&self) -> &str {
match self {
HistorySnapshot::Delta(d) => &d.label,
HistorySnapshot::ObjectVisibility(v) => &v.label,
}
}
/// Approximate retained history memory. Entity images are shared through
/// `Arc`; the estimate conservatively budgets their payloads and optional
/// structure state to keep pathological histories bounded.
pub(super) fn estimated_bytes(&self) -> usize {
match self {
HistorySnapshot::Delta(d) => d
.entities
.len()
.saturating_mul(512)
.saturating_add(
d.structure
.as_ref()
.map_or(0, StructureSnapshot::estimated_bytes),
)
.saturating_add(d.selected_before.len().saturating_mul(16))
.saturating_add(d.selected_after.len().saturating_mul(16))
.saturating_add(d.active_layer.as_ref().map_or(0, |(before, after)| {
before.len().saturating_add(after.len())
}))
.saturating_add(
d.parametric_constraints
.iter()
.map(|entry| {
entry
.before
.constraints
.len()
.saturating_add(entry.after.constraints.len())
})
.sum::<usize>()
.saturating_mul(96),
)
.saturating_add(d.named_parameters.as_ref().map_or(0, |(before, after)| {
before.len().saturating_add(after.len()).saturating_mul(128)
}))
.saturating_add(d.label.len()),
HistorySnapshot::ObjectVisibility(v) => v
.before
.hidden
.len()
.saturating_add(v.before.keep.as_ref().map_or(0, rustc_hash::FxHashSet::len))
.saturating_add(v.after.hidden.len())
.saturating_add(v.after.keep.as_ref().map_or(0, rustc_hash::FxHashSet::len))
.saturating_add(v.selected_before.len())
.saturating_add(v.selected_after.len())
.saturating_mul(16)
.saturating_add(v.label.len()),
}
}
}
/// Symmetric undo/redo image for session-only object visibility. It contains
/// no document entity data and therefore can never make isolation serializable.
#[derive(Clone)]
pub(super) struct ObjectVisibilitySnapshot {
pub(super) before: ObjectIsolationState,
pub(super) after: ObjectIsolationState,
pub(super) selected_before: Vec<Handle>,
pub(super) selected_after: Vec<Handle>,
pub(super) label: String,
}
/// A transactional undo entry: for each touched handle, its before-image and
/// after-image (`None` = the entity was absent on that side, i.e. an add or an
/// erase). Symmetric — undo applies the before side, redo the after side — so
/// one delta moves between the undo and redo stacks without re-capturing. The
/// Optional structure state covers layers, objects, blocks and tables without
/// cloning the flat entity store.
#[derive(Clone)]
pub(super) struct DeltaSnapshot {
pub(super) entities: Vec<(Handle, Option<Arc<EntityType>>, Option<Arc<EntityType>>)>,
pub(super) current_layout_before: String,
pub(super) current_layout_after: String,
pub(super) selected_before: Vec<Handle>,
pub(super) selected_after: Vec<Handle>,
pub(super) dirty_before: bool,
pub(super) dirty_after: bool,
pub(super) active_layer: Option<(String, String)>,
/// Opposite non-entity document state. `apply_delta_state` swaps this with
/// the live structure, so the same allocation shuttles between undo/redo.
pub(super) structure: Option<StructureSnapshot>,
/// Parametric-constraint scopes this same command changed alongside its
/// entities (e.g. ERASE removing a constraint set's touched constraints)
/// — `parametric_constraints` lives on `Scene`, not in `document`/
/// `document.objects`, so it needs its own channel here rather than
/// riding along with `entities`/`structure`. Almost always empty.
pub(super) parametric_constraints: Vec<ParametricConstraintsEntryDelta>,
/// Drawing-wide parameter table changed by this transaction.
pub(super) named_parameters: Option<(
crate::scene::named_parameters::ParameterTable,
crate::scene::named_parameters::ParameterTable,
)>,
pub(super) label: String,
}
#[derive(Clone)]
pub(super) enum StructureSnapshot {
/// Compatibility fallback for genuinely broad structural commands.
Full(CadDocument),
/// Exact layer-table entries touched by one command.
Layers(Vec<TableEntryDelta<codec::tables::Layer>>),
/// Exact text-style entries touched by one command.
TextStyles(Vec<TableEntryDelta<codec::tables::TextStyle>>),
/// Exact dimension-style entries touched by one command.
DimStyles(Vec<TableEntryDelta<codec::tables::DimStyle>>),
/// Exact object-map entries touched by one command. This supports commands
/// such as groups/dictionaries without retaining every unrelated object.
Objects(Vec<ObjectEntryDelta>),
/// The bounded set of style tables, style objects, current-style pointers,
/// and matching ribbon state touched by one Style Manager transaction.
Styles {
before: super::style_ops::StyleStateSnapshot,
after: super::style_ops::StyleStateSnapshot,
text_names: Vec<String>,
dim_names: Vec<String>,
object_handles: Vec<Handle>,
},
}
impl StructureSnapshot {
pub(super) fn estimated_bytes(&self) -> usize {
match self {
Self::Full(doc) => doc.objects.len().saturating_mul(192),
Self::Layers(entries) => entries.len().saturating_mul(256),
Self::TextStyles(entries) => entries.len().saturating_mul(320),
Self::DimStyles(entries) => entries.len().saturating_mul(1024),
Self::Objects(entries) => entries.len().saturating_mul(384),
Self::Styles { before, after, .. } => before
.estimated_bytes()
.saturating_add(after.estimated_bytes()),
}
}
pub(super) fn is_full(&self) -> bool {
matches!(self, Self::Full(_))
}
}
#[derive(Clone)]
pub(super) struct TableEntryDelta<T> {
pub(super) name: String,
pub(super) before: Option<T>,
pub(super) after: Option<T>,
}
#[derive(Clone)]
pub(super) struct ObjectEntryDelta {
pub(super) handle: Handle,
pub(super) before: Option<codec::objects::ObjectType>,
pub(super) after: Option<codec::objects::ObjectType>,
}
/// One parametric-constraint scope's before/after image within an entity delta.
/// This keeps constraint cleanup atomic with edits such as entity erasure.
#[derive(Clone)]
pub(super) struct ParametricConstraintsEntryDelta {
pub(super) scope: crate::scene::parametric_constraints::ParametricScope,
pub(super) before: crate::scene::parametric_constraints::ParametricConstraintSet,
pub(super) after: crate::scene::parametric_constraints::ParametricConstraintSet,
}
#[derive(Default)]
pub(super) struct HistoryState {
pub(super) undo_stack: Vec<HistorySnapshot>,
pub(super) redo_stack: Vec<HistorySnapshot>,
pub(super) pending: Option<PendingHistorySnapshot>,
}
pub(super) struct PendingHistorySnapshot {
pub(super) label: String,
pub(super) current_layout: String,
pub(super) active_layer: String,
pub(super) selected_before: Vec<Handle>,
pub(super) dirty_before: bool,
pub(super) structure_before: CadDocument,
pub(super) recorder: Arc<codec::document::EntityChangeRecorder>,
}