feat(layers): add native state manager

Replace session-only layer snapshots with drawing-backed DWG and DXF states, exposed through the ribbon and LAYERSTATE commands.\n\nCloses #562
This commit is contained in:
Hakan Seven 2026-07-29 18:00:41 +03:00
commit 1e624cc6b7
12 changed files with 563 additions and 84 deletions

View file

@ -212,22 +212,10 @@ impl OpenCADStudio {
}
// LAYERSTATE — save / restore named snapshots of all layer states
// (on/off, freeze, lock, colour, linetype, lineweight).
// in the drawing's native ACAD_LAYERSTATES dictionary.
// LAYERSTATE SAVE <name> | RESTORE <name> | DELETE <name> | ? (list)
"LAYERSTATE" | "LAS" | "LMAN" => {
use crate::command::KeywordCommand;
let c = KeywordCommand::new(
"LAYERSTATE",
"LAYERSTATE [List / Save / Restore / Delete]:",
vec![
("List", "LIST", None),
("Save", "SAVE", Some("LAYERSTATE SAVE new state name:")),
("Restore", "RESTORE", Some("LAYERSTATE RESTORE state name:")),
("Delete", "DELETE", Some("LAYERSTATE DELETE state name:")),
],
);
self.command_line.push_info(&c.prompt());
self.tabs[i].active_cmd = Some(Box::new(c));
return Some(Task::done(Message::LayerStateManagerOpen));
}
cmd if cmd.starts_with("LAYERSTATE ")
|| cmd.starts_with("LAS ")
@ -243,13 +231,14 @@ impl OpenCADStudio {
let arg = parts.next().unwrap_or("").trim();
match sub.as_str() {
"" | "?" | "LIST" => {
let states = &self.tabs[i].layer_states;
let states = self.tabs[i].scene.document.layer_states();
if states.is_empty() {
self.command_line.push_info(
"LAYERSTATE: no saved states. Use LAYERSTATE SAVE <name>.",
);
} else {
let mut names: Vec<&str> = states.keys().map(|s| s.as_str()).collect();
let mut names: Vec<&str> =
states.iter().map(|state| state.name.as_str()).collect();
names.sort_unstable();
self.command_line
.push_output(&format!("Saved layer states: {}", names.join(", ")));
@ -259,7 +248,18 @@ impl OpenCADStudio {
if arg.is_empty() {
self.command_line.push_info("Usage: LAYERSTATE SAVE <name>");
} else {
self.tabs[i].save_layer_state(arg);
let description = self.tabs[i]
.scene
.document
.layer_state(arg)
.map(|state| state.description)
.unwrap_or_default();
self.push_undo_snapshot(i, "LAYERSTATE SAVE");
self.tabs[i]
.scene
.document
.capture_layer_state(arg, description);
self.tabs[i].dirty = true;
self.command_line
.push_output(&format!("LAYERSTATE: saved \"{arg}\"."));
}
@ -268,7 +268,7 @@ impl OpenCADStudio {
if arg.is_empty() {
self.command_line
.push_info("Usage: LAYERSTATE RESTORE <name>");
} else if !self.tabs[i].layer_states.contains_key(arg) {
} else if self.tabs[i].scene.document.layer_state(arg).is_none() {
self.command_line.push_error(&format!(
"LAYERSTATE: no saved state named \"{arg}\"."
));
@ -280,11 +280,20 @@ impl OpenCADStudio {
.iter()
.map(|layer| layer.name.clone())
.collect();
let undo = self.begin_layer_undo(i, "LAYERSTATE", &names);
let n = self.tabs[i].restore_layer_state(arg).unwrap_or(0);
self.push_undo_snapshot(i, "LAYERSTATE RESTORE");
let n = self.tabs[i]
.scene
.document
.restore_layer_state(arg)
.unwrap_or(0);
self.tabs[i].active_layer = self.tabs[i]
.scene
.document
.header
.current_layer_name
.clone();
self.tabs[i].scene.invalidate_layer_dependencies(&names);
self.tabs[i].dirty = true;
self.commit_layer_undo(i, undo);
self.refresh_layer_panel();
self.command_line.push_output(&format!(
"LAYERSTATE: restored \"{arg}\" ({n} layer(s))."
@ -292,13 +301,19 @@ impl OpenCADStudio {
}
}
"DELETE" | "D" => {
if self.tabs[i].layer_states.remove(arg).is_some() {
if arg.is_empty() {
self.command_line
.push_output(&format!("LAYERSTATE: deleted \"{arg}\"."));
} else {
.push_info("Usage: LAYERSTATE DELETE <name>");
} else if self.tabs[i].scene.document.layer_state(arg).is_none() {
self.command_line.push_error(&format!(
"LAYERSTATE: no saved state named \"{arg}\"."
));
} else {
self.push_undo_snapshot(i, "LAYERSTATE DELETE");
self.tabs[i].scene.document.delete_layer_state(arg);
self.tabs[i].dirty = true;
self.command_line
.push_output(&format!("LAYERSTATE: deleted \"{arg}\"."));
}
}
_ => {

View file

@ -102,60 +102,6 @@ fn default_role_for(component: DynComponent) -> crate::command::DynRole {
// ── Per-document tab state ─────────────────────────────────────────────────
/// One layer's display state, captured and restored by LAYERSTATE.
#[derive(Clone)]
pub(super) struct LayerSnap {
pub name: String,
pub off: bool,
pub frozen: bool,
pub locked: bool,
pub color: acadrust::types::Color,
pub line_type: String,
pub line_weight: acadrust::types::LineWeight,
}
impl DocumentTab {
/// Snapshot every layer's current display state under `name` (overwrites
/// any existing state of that name).
pub(super) fn save_layer_state(&mut self, name: &str) {
let snaps: Vec<LayerSnap> = self
.scene
.document
.layers
.iter()
.map(|l| LayerSnap {
name: l.name.clone(),
off: l.flags.off,
frozen: l.flags.frozen,
locked: l.flags.locked,
color: l.color.clone(),
line_type: l.line_type.clone(),
line_weight: l.line_weight.clone(),
})
.collect();
self.layer_states.insert(name.to_string(), snaps);
}
/// Reapply the saved state `name` to the matching layers; returns the number
/// of layers updated, or `None` if no such state exists.
pub(super) fn restore_layer_state(&mut self, name: &str) -> Option<usize> {
let snaps = self.layer_states.get(name)?.clone();
let mut applied = 0usize;
for s in &snaps {
if let Some(l) = self.scene.document.layers.get_mut(&s.name) {
l.flags.off = s.off;
l.flags.frozen = s.frozen;
l.flags.locked = s.locked;
l.color = s.color.clone();
l.line_type = s.line_type.clone();
l.line_weight = s.line_weight.clone();
applied += 1;
}
}
Some(applied)
}
}
pub(super) struct DocumentTab {
/// Stable identity across tab insert/remove operations. Background work
/// must never target a tab by its transient vector index.
@ -221,8 +167,6 @@ pub(super) struct DocumentTab {
pub(super) dyn_active: usize,
pub(super) history: HistoryState,
pub(super) active_layer: String,
/// Named layer-state snapshots saved by LAYERSTATE (name → per-layer state).
pub(super) layer_states: std::collections::HashMap<String, Vec<LayerSnap>>,
/// Currently active UCS. `None` means WCS (identity transform).
pub(super) active_ucs: Option<Ucs>,
/// Custom model-space background color. `None` = default dark grey.
@ -486,7 +430,6 @@ impl DocumentTab {
dyn_active: 0,
history: HistoryState::default(),
active_layer: "0".to_string(),
layer_states: std::collections::HashMap::new(),
active_ucs: None,
bg_color: None,
paper_bg_color: None,

View file

@ -2,6 +2,36 @@ use super::OpenCADStudio;
use crate::ui;
impl OpenCADStudio {
pub(super) fn load_layer_state_editor(&mut self, selected: Option<String>) {
let i = self.active_tab;
if let Some(name) = selected {
if let Some(state) = self.tabs[i].scene.document.layer_state(&name) {
self.layer_state_name_buf = state.name.clone();
self.layer_state_description_buf = state.description;
self.layer_state_selected = Some(state.name);
return;
}
}
self.layer_state_selected = None;
self.layer_state_description_buf.clear();
let names: Vec<String> = self.tabs[i]
.scene
.document
.layer_states()
.into_iter()
.map(|state| state.name)
.collect();
let n = (1usize..)
.find(|n| {
let candidate = format!("Layer State {n}");
!names
.iter()
.any(|name| name.eq_ignore_ascii_case(&candidate))
})
.unwrap_or(1);
self.layer_state_name_buf = format!("Layer State {n}");
}
/// Reload the `LayerPanel` cache from the document and push the
/// fresh state through to the ribbon dropdown + every other
/// layer-aware UI mirror. Use this whenever a command mutates the

View file

@ -735,6 +735,12 @@ pub(super) struct OpenCADStudio {
layout_manager_selected: String,
layout_manager_rename_buf: String,
// ── Layer State Manager ───────────────────────────────────────────────
layer_state_selected: Option<String>,
layer_state_name_buf: String,
layer_state_description_buf: String,
layer_state_filter: String,
// ── Annotation-scale Manager ──────────────────────────────────────────
scale_manager_selected: String,
scale_manager_paper_buf: String,
@ -1317,6 +1323,7 @@ pub enum ModalKind {
PluginManager,
UpdateNotice,
Layers,
LayerStateManager,
Plot,
LayoutManager,
Plotstyle,
@ -1671,6 +1678,16 @@ pub enum Message {
LayerLineweightSet(LineWeight),
LayerTransparencyEdit(usize, String),
LayerRenameCommit,
// ── Layer State Manager ─────────────────────────────────────────────
LayerStateManagerOpen,
LayerStateManagerSelect(String),
LayerStateManagerNew,
LayerStateManagerFilter(String),
LayerStateManagerName(String),
LayerStateManagerDescription(String),
LayerStateManagerSave,
LayerStateManagerRestore,
LayerStateManagerDelete,
CursorMoved(Point),
ViewportClick,
ViewportMove(Point),
@ -2708,6 +2725,10 @@ impl OpenCADStudio {
alias_editor_rows: Vec::new(),
// Layout Manager
layout_manager_selected: "Model".to_string(),
layer_state_selected: None,
layer_state_name_buf: String::new(),
layer_state_description_buf: String::new(),
layer_state_filter: String::new(),
scale_manager_selected: String::new(),
scale_manager_paper_buf: String::new(),
scale_manager_drawing_buf: String::new(),

View file

@ -1009,9 +1009,25 @@ impl OpenCADStudio {
}
self.active_tab = idx;
if self.tabs[idx].is_start
&& self.active_modal == Some(super::ModalKind::LayoutManager)
&& matches!(
self.active_modal,
Some(
super::ModalKind::LayoutManager
| super::ModalKind::LayerStateManager
)
)
{
self.close_active_modal();
} else if self.active_modal == Some(super::ModalKind::LayerStateManager) {
let mut names: Vec<String> = self.tabs[idx]
.scene
.document
.layer_states()
.into_iter()
.map(|state| state.name)
.collect();
names.sort_by_key(|name| name.to_lowercase());
self.load_layer_state_editor(names.into_iter().next());
}
self.sync_ribbon_layers();
self.sync_ribbon_styles();
@ -1401,6 +1417,150 @@ impl OpenCADStudio {
Task::none()
}
Message::LayerStateManagerOpen => {
let i = self.active_tab;
self.ribbon.close_dropdown();
if self.tabs[i].is_start {
self.command_line
.push_info("Open or create a drawing to manage layer states.");
return Task::none();
}
let mut names: Vec<String> = self.tabs[i]
.scene
.document
.layer_states()
.into_iter()
.map(|state| state.name)
.collect();
names.sort_by_key(|name| name.to_lowercase());
self.load_layer_state_editor(names.into_iter().next());
self.active_modal = Some(super::ModalKind::LayerStateManager);
Task::none()
}
Message::LayerStateManagerSelect(name) => {
self.load_layer_state_editor(Some(name));
Task::none()
}
Message::LayerStateManagerNew => {
self.load_layer_state_editor(None);
Task::none()
}
Message::LayerStateManagerFilter(value) => {
self.layer_state_filter = value;
Task::none()
}
Message::LayerStateManagerName(value) => {
self.layer_state_name_buf = value;
Task::none()
}
Message::LayerStateManagerDescription(value) => {
self.layer_state_description_buf = value;
Task::none()
}
Message::LayerStateManagerSave => {
let i = self.active_tab;
let name = self.layer_state_name_buf.trim().to_string();
if name.is_empty() {
self.command_line
.push_error("Layer state name cannot be empty.");
return Task::none();
}
let old_name = self.layer_state_selected.clone();
let duplicate = self.tabs[i]
.scene
.document
.layer_states()
.into_iter()
.any(|state| {
state.name.eq_ignore_ascii_case(&name)
&& old_name
.as_deref()
.is_none_or(|old| !state.name.eq_ignore_ascii_case(old))
});
if duplicate {
self.command_line
.push_error(&format!("Layer state \"{name}\" already exists."));
return Task::none();
}
self.push_undo_snapshot(i, "LAYERSTATE SAVE");
if let Some(old_name) = old_name.as_deref() {
if !old_name.eq_ignore_ascii_case(&name) {
self.tabs[i]
.scene
.document
.rename_layer_state(old_name, &name);
}
}
self.tabs[i].scene.document.capture_layer_state(
&name,
self.layer_state_description_buf.trim(),
);
self.tabs[i].dirty = true;
self.layer_state_selected = Some(name.clone());
self.layer_state_name_buf = name.clone();
self.command_line
.push_output(&format!("LAYERSTATE: saved \"{name}\" in the drawing."));
Task::none()
}
Message::LayerStateManagerRestore => {
let i = self.active_tab;
let Some(name) = self.layer_state_selected.clone() else {
return Task::none();
};
let layer_names: Vec<String> = self.tabs[i]
.scene
.document
.layers
.iter()
.map(|layer| layer.name.clone())
.collect();
self.push_undo_snapshot(i, "LAYERSTATE RESTORE");
let restored = self.tabs[i]
.scene
.document
.restore_layer_state(&name)
.unwrap_or(0);
let active = self.tabs[i]
.scene
.document
.header
.current_layer_name
.clone();
self.tabs[i].active_layer = active;
self.tabs[i]
.scene
.invalidate_layer_dependencies(&layer_names);
self.tabs[i].dirty = true;
self.refresh_layer_panel();
self.command_line.push_output(&format!(
"LAYERSTATE: restored \"{name}\" ({restored} layer(s))."
));
Task::none()
}
Message::LayerStateManagerDelete => {
let i = self.active_tab;
let Some(name) = self.layer_state_selected.clone() else {
return Task::none();
};
self.push_undo_snapshot(i, "LAYERSTATE DELETE");
if self.tabs[i].scene.document.delete_layer_state(&name) {
self.tabs[i].dirty = true;
let mut names: Vec<String> = self.tabs[i]
.scene
.document
.layer_states()
.into_iter()
.map(|state| state.name)
.collect();
names.sort_by_key(|name| name.to_lowercase());
self.load_layer_state_editor(names.into_iter().next());
self.command_line
.push_output(&format!("LAYERSTATE: deleted \"{name}\"."));
}
Task::none()
}
Message::WindowCloseRequested(id) => {
if self.main_window == Some(id) {
if self.tabs.iter().any(|t| t.dirty) {

View file

@ -1571,6 +1571,7 @@ impl OpenCADStudio {
PluginManager => (940, 600),
UpdateNotice => (560, 460),
Layers => (900, 360),
LayerStateManager => (720, 420),
Plot => (760, 540),
LayoutManager => (640, 320),
Plotstyle => (780, 540),

View file

@ -15,6 +15,7 @@ impl OpenCADStudio {
Some(K::PluginManager) => "Plugin Manager",
Some(K::UpdateNotice) => "Update Available",
Some(K::Layers) => "Layer Manager",
Some(K::LayerStateManager) => "Layer State Manager",
Some(K::Plot) => "Plot",
Some(K::LayoutManager) => "Layout Manager",
Some(K::ScaleManager) => "Scale Manager",
@ -119,6 +120,20 @@ impl OpenCADStudio {
let tab = &self.tabs[self.active_tab];
sized(tab.layers.view_window(self.layer_name_col_w), 900, 360)
}
super::super::ModalKind::LayerStateManager => {
let states = self.tabs[self.active_tab].scene.document.layer_states();
sized(
crate::ui::window::layer_state_manager::view_window(
states,
self.layer_state_selected.as_deref(),
&self.layer_state_name_buf,
&self.layer_state_description_buf,
&self.layer_state_filter,
),
720,
420,
)
}
super::super::ModalKind::Plot => {
sized(crate::ui::window::plot::view_window(&self.plot_dialog), 760, 540)
}