From da2c600953cced0ad611210d68ef13e61c6ff095 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 10 Jul 2026 15:45:08 +0300 Subject: [PATCH] feat(sysvars): persist Ortho, running OSNAP and Isolate in the drawing Ortho and the running object-snap set were app-global (settings.txt only) and Isolate/Hide was a session-only view state, so none survived save/reopen and none were per-drawing. Bind all three to the file like AutoCAD: - Ortho -> header.ortho_mode ($ORTHOMODE); running OSNAP <-> header .object_snap_mode ($OSMODE bitmask) via a new SnapType<->bit map in settings (osmode_from_snaps / snaps_from_osmode, round-trip tested). The header is the per-drawing store, live state mirrors the active drawing: adopt header->live on open and tab-switch, stamp live->header on save and when leaving a tab, a fresh drawing inherits the current values, and SETVAR ORTHOMODE/OSMODE now also update the live state. settings.txt stays as the boot/new-drawing default; the per-file header overrides it on open. - Isolate/Hide now set each entity's persisted invisible flag (DXF code 60, which acadrust round-trips in DXF and DWG) in addition to the session set; End Isolation clears it. The set is reseeded from invisible entities on load and after undo/redo, the commands are undoable and mark the tab dirty. The renderer already skipped invisible entities, so no render change was needed. Co-Authored-By: Claude Opus 4.8 --- src/app/commands/layers.rs | 6 +++ src/app/commands/styleprops.rs | 16 ++++++ src/app/history.rs | 3 ++ src/app/settings.rs | 97 ++++++++++++++++++++++++++++++++++ src/app/update/file.rs | 51 ++++++++++++++++++ src/app/update/mod.rs | 12 +++++ src/scene/mod.rs | 38 +++++++++++-- 7 files changed, 220 insertions(+), 3 deletions(-) diff --git a/src/app/commands/layers.rs b/src/app/commands/layers.rs index cfddbd0b6..403edb1f4 100644 --- a/src/app/commands/layers.rs +++ b/src/app/commands/layers.rs @@ -444,7 +444,9 @@ impl OpenCADStudio { .push_error("ISOLATEOBJECTS: select the objects to isolate first."); } else { let n = self.tabs[i].scene.selected.len(); + self.push_undo_snapshot(i, "ISOLATEOBJECTS"); self.tabs[i].scene.isolate_selected(); + self.tabs[i].dirty = true; self.command_line.push_info(&format!( "Isolated {n} object(s). UNISOLATEOBJECTS to restore." )); @@ -458,7 +460,9 @@ impl OpenCADStudio { .push_error("HIDEOBJECTS: select the objects to hide first."); } else { let n = self.tabs[i].scene.selected.len(); + self.push_undo_snapshot(i, "HIDEOBJECTS"); self.tabs[i].scene.hide_selected(); + self.tabs[i].dirty = true; self.command_line .push_info(&format!("Hid {n} object(s). UNISOLATEOBJECTS to restore.")); } @@ -467,7 +471,9 @@ impl OpenCADStudio { // UNISOLATEOBJECTS — bring back everything hidden by Isolate / Hide "UNISOLATEOBJECTS" => { if self.tabs[i].scene.is_isolation_active() { + self.push_undo_snapshot(i, "UNISOLATEOBJECTS"); self.tabs[i].scene.end_isolation(); + self.tabs[i].dirty = true; self.command_line .push_info("Isolation ended — all objects shown."); } else { diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index f44ba9df5..8444fb544 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -1250,6 +1250,22 @@ impl OpenCADStudio { if name == "TEXTFILL" { self.tabs[i].scene.bump_geometry(); } + // ORTHOMODE / OSMODE set the header directly; mirror them into + // the live Ortho / running OSNAP so the constraint + status + // bar follow and the save-time stamp doesn't revert them. + if name == "ORTHOMODE" { + self.ortho_mode = self.tabs[i].scene.document.header.ortho_mode; + if self.ortho_mode { + self.polar_mode = false; + } + } + if name == "OSMODE" { + let (modes, en) = crate::app::settings::snaps_from_osmode( + self.tabs[i].scene.document.header.object_snap_mode, + ); + self.snapper.enabled = modes.into_iter().collect(); + self.snapper.snap_enabled = en; + } } } diff --git a/src/app/history.rs b/src/app/history.rs index 39c476e99..35230bc69 100644 --- a/src/app/history.rs +++ b/src/app/history.rs @@ -45,6 +45,9 @@ impl OpenCADStudio { self.tabs[i].scene.populate_hatches_from_document(); self.tabs[i].scene.populate_images_from_document(); self.tabs[i].scene.populate_meshes_from_document(); + // Keep the Isolate/Hide set in step with the restored per-entity + // visibility so End Isolation stays correct after undo/redo. + self.tabs[i].scene.sync_hidden_from_invisible(); self.tabs[i].scene.clear_preview_wire(); self.tabs[i].scene.images.clear(); self.tabs[i].active_cmd = None; diff --git a/src/app/settings.rs b/src/app/settings.rs index 943091ee7..c58f1cb6e 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -58,6 +58,61 @@ fn snap_from_id(s: &str) -> Option { SNAP_ORDER.iter().copied().find(|t| snap_id(*t) == s) } +/// `$OSMODE` bit for each running object-snap mode (standard AutoCAD bitmask). +/// `None` for OCS-only snaps (Grid, ObjectPick) that have no standard bit. +fn snap_bit(s: SnapType) -> Option { + Some(match s { + SnapType::Endpoint => 1, + SnapType::Midpoint => 2, + SnapType::Center => 4, + SnapType::Node => 8, + SnapType::Quadrant => 16, + SnapType::Intersection => 32, + SnapType::Insertion => 64, + SnapType::Perpendicular => 128, + SnapType::Tangent => 256, + SnapType::Nearest => 512, + SnapType::ApparentIntersection => 2048, + SnapType::Extension => 4096, + SnapType::Parallel => 8192, + SnapType::Grid | SnapType::ObjectPick => return None, + }) +} + +/// Bit 14 of `$OSMODE` — object snap turned off (master suppress). +const OSMODE_SUPPRESS: i32 = 16384; + +/// Encode the running-snap set + master toggle into an `$OSMODE` bitmask for the +/// drawing header. OCS-only snaps (Grid, ObjectPick) have no bit and are +/// dropped. A cleared master toggle sets the suppress bit. +pub(crate) fn osmode_from_snaps<'a>( + enabled: impl IntoIterator, + snap_enabled: bool, +) -> i32 { + let mut bits = 0; + for t in enabled { + if let Some(b) = snap_bit(*t) { + bits |= b; + } + } + if !snap_enabled { + bits |= OSMODE_SUPPRESS; + } + bits +} + +/// Decode an `$OSMODE` bitmask into `(running-snap set, master enabled)`. Only +/// the standard mappable modes are produced; the suppress bit maps (inverted) to +/// the master toggle. +pub(crate) fn snaps_from_osmode(osmode: i32) -> (Vec, bool) { + let modes = SNAP_ORDER + .iter() + .copied() + .filter(|t| snap_bit(*t).is_some_and(|b| osmode & b != 0)) + .collect(); + (modes, osmode & OSMODE_SUPPRESS == 0) +} + /// Parse a persisted `r,g,b` background triplet (each 0–255). Returns `None` /// for an empty or malformed value so a missing/garbage key falls back to the /// app default rather than a wrong colour. @@ -286,3 +341,45 @@ fn config_path() -> Option { p.push("OpenCADStudio"); Some(p.join("settings.txt")) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn osmode_encodes_bits_and_suppress() { + // Endpoint(1) + Midpoint(2) + Intersection(32) = 35, master on. + let on = [SnapType::Endpoint, SnapType::Midpoint, SnapType::Intersection]; + assert_eq!(osmode_from_snaps(on.iter(), true), 35); + // Master off sets the suppress bit (16384). + assert_eq!(osmode_from_snaps(on.iter(), false), 35 | 16384); + // OCS-only snaps carry no bit and are dropped. + assert_eq!(osmode_from_snaps([SnapType::Grid, SnapType::ObjectPick].iter(), true), 0); + } + + #[test] + fn osmode_decodes_bits_and_suppress() { + let (modes, enabled) = snaps_from_osmode(35); + let set: std::collections::HashSet<_> = modes.into_iter().collect(); + assert!(enabled); + assert_eq!(set.len(), 3); + assert!(set.contains(&SnapType::Endpoint)); + assert!(set.contains(&SnapType::Midpoint)); + assert!(set.contains(&SnapType::Intersection)); + // Suppress bit → master off; the mode bits still decode. + let (_m, en) = snaps_from_osmode(35 | 16384); + assert!(!en); + } + + #[test] + fn osmode_round_trips_every_mappable_mode() { + // All 13 running snaps + master on encode and decode back to the same set. + let all: Vec = SNAP_ORDER.to_vec(); + let bits = osmode_from_snaps(all.iter(), true); + let (back, enabled) = snaps_from_osmode(bits); + assert!(enabled); + let a: std::collections::HashSet<_> = all.into_iter().collect(); + let b: std::collections::HashSet<_> = back.into_iter().collect(); + assert_eq!(a, b); + } +} diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 7a9647378..a174016e1 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -116,6 +116,45 @@ impl OpenCADStudio { self.rebuild_ribbon_modules(); } + /// Adopt the per-drawing sysvars stored in tab `i`'s document header — + /// Ortho (`$ORTHOMODE`) and the running object-snap set (`$OSMODE`) — into + /// the live app state, so each drawing keeps its own. Called when a drawing + /// becomes active (open, tab switch). The app-settings values seed the boot + /// default; the per-file header overrides it here. + pub(in crate::app) fn adopt_header_sysvars(&mut self, i: usize) { + if self.tabs[i].is_start { + return; + } + let (ortho, osmode) = { + let h = &self.tabs[i].scene.document.header; + (h.ortho_mode, h.object_snap_mode) + }; + self.ortho_mode = ortho; + // Ortho and Polar are mutually exclusive (ToggleOrtho clears Polar). + if ortho { + self.polar_mode = false; + } + let (modes, snap_enabled) = crate::app::settings::snaps_from_osmode(osmode); + self.snapper.enabled = modes.into_iter().collect(); + self.snapper.snap_enabled = snap_enabled; + } + + /// Stamp the live per-drawing sysvars (Ortho, running OSNAP) onto tab `i`'s + /// document header just before it is written to disk (or before switching + /// away from it), so they persist with the drawing. + pub(in crate::app) fn stamp_header_sysvars(&mut self, i: usize) { + if self.tabs[i].is_start { + return; + } + let osmode = crate::app::settings::osmode_from_snaps( + self.snapper.enabled.iter(), + self.snapper.snap_enabled, + ); + let h = &mut self.tabs[i].scene.document.header; + h.ortho_mode = self.ortho_mode; + h.object_snap_mode = osmode; + } + /// 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. @@ -311,6 +350,9 @@ pub(super) fn on_open_file(&mut self) -> Task { 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(); + // Adopt the drawing's own Ortho ($ORTHOMODE) and running OSNAP + // ($OSMODE) so each file keeps its state, like AutoCAD. + self.adopt_header_sysvars(i); // 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 @@ -406,6 +448,10 @@ pub(super) fn on_open_file(&mut self) -> Task { 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(); + // Rebuild the Isolate/Hide set from the entities the file itself + // marks invisible (DXF code 60), so hidden objects stay hidden on + // reopen and End Isolation can bring them back. + self.tabs[i].scene.sync_hidden_from_invisible(); crate::io::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 @@ -594,6 +640,9 @@ pub(super) fn on_open_file(&mut self) -> Task { // reflects them even if they came from settings with no // in-session toggle (#121). self.sync_vport_display(i); + // Persist Ortho ($ORTHOMODE) + running OSNAP ($OSMODE) into the + // drawing header so they survive save/reopen (per-drawing). + self.stamp_header_sysvars(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"))] @@ -662,6 +711,8 @@ pub(super) fn on_open_file(&mut self) -> Task { let close = self.close_save_dialog_window(); let i = self.active_tab; sync_annotation_scale_header(&mut self.tabs[i].scene); + // Persist Ortho / running OSNAP into the header (Save-As path). + self.stamp_header_sysvars(i); self.sync_truck_solids_to_acis(i); // Native: write to the chosen path. Web: download the bytes diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 1b93da79a..eb0f7d566 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -664,11 +664,17 @@ impl OpenCADStudio { // ── Document tabs ───────────────────────────────────────────── Message::TabNew => { + // Preserve the outgoing drawing's Ortho / running OSNAP. + self.stamp_header_sysvars(self.active_tab); self.tab_counter += 1; let new_tab = super::document::DocumentTab::new_drawing(self.tab_counter); self.tabs.push(new_tab); self.active_tab = self.tabs.len() - 1; let idx = self.active_tab; + // A fresh drawing inherits the app's current Ortho / OSNAP so + // creating one doesn't silently reset them (its header default is + // 0 = no snaps); saving then persists them into the file. + self.stamp_header_sysvars(idx); self.apply_bg_default(idx); self.sync_ribbon_layers(); self.sync_ribbon_styles(); @@ -687,6 +693,10 @@ impl OpenCADStudio { // The attribute editor is tab-scoped; leaving its tab // drops it (its handle is that document's, not this one's). self.cancel_attr_editor(); + // Persist the outgoing drawing's Ortho / running OSNAP + // before leaving it, so switching back restores them. + let prev = self.active_tab; + self.stamp_header_sysvars(prev); } self.active_tab = idx; self.sync_ribbon_layers(); @@ -698,6 +708,8 @@ impl OpenCADStudio { self.sync_ribbon_from_selection(); // Grid/snap follow the newly active drawing's viewport. self.adopt_view_display(idx); + // Ortho / running OSNAP follow the newly active drawing. + self.adopt_header_sysvars(idx); // Shared CJK ideographs follow the newly active drawing's // language; re-tessellate if it differs from the last. (#141) if crate::scene::text::web_font::set_cjk_lang_from_codepage( diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 50a234625..20474934b 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1951,12 +1951,16 @@ impl Scene { return; } let keep = self.selected.clone(); - self.hidden = self + let hide: Vec = self .document .entities() .map(|e| e.common().handle) .filter(|h| !h.is_null() && !keep.contains(h)) .collect(); + // Persist the hidden state on each entity (DXF code 60) so it survives + // save/reopen — the renderer already skips `invisible` entities. + self.set_invisible(&hide, true); + self.hidden = hide.into_iter().collect(); self.selected.clear(); self.bump_geometry(); } @@ -1966,22 +1970,50 @@ impl Scene { if self.selected.is_empty() { return; } - for h in self.selected.iter().copied() { + let sel: Vec = self.selected.iter().copied().collect(); + for h in sel.iter().copied() { self.hidden.insert(h); } + self.set_invisible(&sel, true); self.selected.clear(); self.bump_geometry(); } - /// Clear isolation — bring every hidden entity back (End Isolation). + /// Clear isolation — bring every hidden entity back (End Isolation), + /// clearing the persisted invisible flag too so the reveal is saved. pub fn end_isolation(&mut self) { if self.hidden.is_empty() { return; } + let restore: Vec = self.hidden.iter().copied().collect(); + self.set_invisible(&restore, false); self.hidden.clear(); self.bump_geometry(); } + /// Set the persisted visibility flag (DXF code 60) on each handle. + fn set_invisible(&mut self, handles: &[Handle], invisible: bool) { + for &h in handles { + if let Some(e) = self.document.get_entity_mut(h) { + e.common_mut().invisible = invisible; + } + } + } + + /// Rebuild the Isolate/Hide set (`hidden`) from the entities the document + /// currently marks invisible (DXF code 60). Call after loading a file or + /// restoring an undo/redo snapshot so the session set matches the persisted + /// per-entity visibility (and End Isolation stays available). + pub fn sync_hidden_from_invisible(&mut self) { + self.hidden = self + .document + .entities() + .filter(|e| e.common().invisible) + .map(|e| e.common().handle) + .filter(|h| !h.is_null()) + .collect(); + } + /// True if any currently selected entity is a Viewport. /// Used to enable the scale picker when a viewport is selected in paper space. pub fn has_selected_viewport(&self) -> bool {