perf: keep heavy work off UI thread

This commit is contained in:
Hakan Seven 2026-07-25 18:49:18 +03:00
commit deec48ad99
75 changed files with 11703 additions and 519 deletions

View file

@ -1052,9 +1052,10 @@ impl OpenCADStudio {
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.tabs[i].scene.clear_preview_wire();
self.push_undo_snapshot(i, "GROUP");
let undo = self.begin_group_undo(i, "GROUP");
self.tabs[i].scene.create_group(name.clone(), handles);
self.tabs[i].dirty = true;
self.commit_group_undo(i, undo);
self.command_line
.push_info(&format!("Group \"{}\" created.", name));
}
@ -1062,9 +1063,10 @@ impl OpenCADStudio {
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.tabs[i].scene.clear_preview_wire();
self.push_undo_snapshot(i, "UNGROUP");
let undo = self.begin_group_undo(i, "UNGROUP");
let count = self.tabs[i].scene.delete_groups_containing(&handles);
self.tabs[i].dirty = true;
self.commit_group_undo(i, undo);
if count > 0 {
self.command_line
.push_info(&format!("{} group(s) dissolved.", count));

View file

@ -817,10 +817,15 @@ impl OpenCADStudio {
self.command_line
.push_error(&format!("DIMSTYLE: '{}' already exists.", name));
} else {
let undo = self.begin_dim_style_undo(
i,
"DIMSTYLE NEW",
std::slice::from_ref(&name),
);
let style = DimStyle::new(&name);
let _ = self.tabs[i].scene.document.dim_styles.add(style);
self.push_undo_snapshot(i, "DIMSTYLE NEW");
self.tabs[i].dirty = true;
self.commit_dim_style_undo(i, undo);
self.command_line
.push_output(&format!("DIMSTYLE: '{}' created.", name));
}
@ -832,6 +837,11 @@ impl OpenCADStudio {
let prop = parts.get(2).map(|s| s.to_lowercase()).unwrap_or_default();
let val_str = parts.get(3).map(|s| s.trim()).unwrap_or("");
if let Ok(val) = val_str.parse::<f64>() {
let undo = self.begin_dim_style_undo(
i,
"DIMSTYLE SET",
std::slice::from_ref(&style_name),
);
if let Some(ds) =
self.tabs[i].scene.document.dim_styles.get_mut(&style_name)
{
@ -882,8 +892,10 @@ impl OpenCADStudio {
return Some(Task::none());
}
}
self.push_undo_snapshot(i, "DIMSTYLE SET");
self.tabs[i].dirty = true;
self.tabs[i].scene
.invalidate_dim_style_dependencies(&style_name);
self.commit_dim_style_undo(i, undo);
self.command_line.push_output(&format!(
"DIMSTYLE: '{style_name}'.{prop} = {val:.3}"
));
@ -1121,10 +1133,15 @@ impl OpenCADStudio {
self.command_line
.push_error(&format!("{prefix}: style '{name}' already exists."));
} else {
let undo = self.begin_text_style_undo(
i,
"STYLE NEW",
std::slice::from_ref(&name),
);
let style = acadrust::tables::TextStyle::new(&name);
let _ = self.tabs[i].scene.document.text_styles.add(style);
self.push_undo_snapshot(i, "STYLE NEW");
self.tabs[i].dirty = true;
self.commit_text_style_undo(i, undo);
self.command_line
.push_output(&format!("{prefix}: style '{name}' created."));
}
@ -1136,18 +1153,28 @@ impl OpenCADStudio {
if style_name.is_empty() || font.is_empty() {
self.command_line
.push_error(&format!("Usage: {prefix} FONT <style> <font_file>"));
} else if let Some(s) =
} else {
let undo = self.begin_text_style_undo(
i,
"STYLE FONT",
std::slice::from_ref(&style_name),
);
if let Some(style) =
self.tabs[i].scene.document.text_styles.get_mut(&style_name)
{
s.font_file = font.clone();
self.push_undo_snapshot(i, "STYLE FONT");
style.font_file = font.clone();
self.tabs[i].dirty = true;
self.tabs[i]
.scene
.invalidate_text_style_dependencies(&style_name);
self.commit_text_style_undo(i, undo);
self.command_line.push_output(&format!(
"{prefix}: '{style_name}' font set to '{font}'."
));
} else {
self.command_line
.push_error(&format!("{prefix}: style '{style_name}' not found."));
}
}
}
"WIDTH" | "W" => {
@ -1155,12 +1182,20 @@ impl OpenCADStudio {
let style_name = parts.get(1).map(|s| s.trim()).unwrap_or("").to_string();
let factor_str = parts.get(2).map(|s| s.trim()).unwrap_or("");
if let Ok(factor) = factor_str.parse::<f64>() {
if let Some(s) =
let undo = self.begin_text_style_undo(
i,
"STYLE WIDTH",
std::slice::from_ref(&style_name),
);
if let Some(style) =
self.tabs[i].scene.document.text_styles.get_mut(&style_name)
{
s.width_factor = factor;
self.push_undo_snapshot(i, "STYLE WIDTH");
style.width_factor = factor;
self.tabs[i].dirty = true;
self.tabs[i]
.scene
.invalidate_text_style_dependencies(&style_name);
self.commit_text_style_undo(i, undo);
self.command_line.push_output(&format!(
"{prefix}: '{style_name}' width factor set to {factor:.3}."
));
@ -1179,12 +1214,20 @@ impl OpenCADStudio {
let style_name = parts.get(1).map(|s| s.trim()).unwrap_or("").to_string();
let angle_str = parts.get(2).map(|s| s.trim()).unwrap_or("");
if let Ok(deg) = angle_str.parse::<f64>() {
if let Some(s) =
let undo = self.begin_text_style_undo(
i,
"STYLE OBLIQUE",
std::slice::from_ref(&style_name),
);
if let Some(style) =
self.tabs[i].scene.document.text_styles.get_mut(&style_name)
{
s.oblique_angle = deg.to_radians();
self.push_undo_snapshot(i, "STYLE OBLIQUE");
style.oblique_angle = deg.to_radians();
self.tabs[i].dirty = true;
self.tabs[i]
.scene
.invalidate_text_style_dependencies(&style_name);
self.commit_text_style_undo(i, undo);
self.command_line.push_output(&format!(
"{prefix}: '{style_name}' oblique angle set to {deg:.1}°."
));

View file

@ -23,7 +23,8 @@ impl OpenCADStudio {
.into_iter()
.map(|(_, e)| e.common().layer.clone())
.collect();
self.push_undo_snapshot(i, "LAYOFF");
let names: Vec<String> = layers.iter().cloned().collect();
let undo = self.begin_layer_undo(i, "LAYOFF", &names);
for name in &layers {
if name == "0" {
continue;
@ -32,8 +33,9 @@ impl OpenCADStudio {
dl.turn_off();
}
}
self.tabs[i].scene.bump_geometry();
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_info("Layer(s) turned off.");
}
@ -58,7 +60,8 @@ impl OpenCADStudio {
.into_iter()
.map(|(_, e)| e.common().layer.clone())
.collect();
self.push_undo_snapshot(i, "LAYFRZ");
let names: Vec<String> = layers.iter().cloned().collect();
let undo = self.begin_layer_undo(i, "LAYFRZ", &names);
for name in &layers {
if name == "0" {
continue;
@ -67,8 +70,9 @@ impl OpenCADStudio {
dl.freeze();
}
}
self.tabs[i].scene.bump_geometry();
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_info("Layer(s) frozen.");
}
@ -266,10 +270,18 @@ impl OpenCADStudio {
"LAYERSTATE: no saved state named \"{arg}\"."
));
} else {
self.push_undo_snapshot(i, "LAYERSTATE");
let names: Vec<String> = self.tabs[i]
.scene
.document
.layers
.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.tabs[i].scene.bump_geometry();
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))."
@ -312,14 +324,16 @@ impl OpenCADStudio {
.into_iter()
.map(|(_, e)| e.common().layer.clone())
.collect();
self.push_undo_snapshot(i, "LAYLCK");
let names: Vec<String> = layers.iter().cloned().collect();
let undo = self.begin_layer_undo(i, "LAYLCK", &names);
for name in &layers {
if let Some(dl) = self.tabs[i].scene.document.layers.get_mut(name) {
dl.lock();
}
}
self.tabs[i].scene.bump_geometry();
// Layer locking changes editability only.
self.tabs[i].dirty = true;
self.commit_layer_undo(i, undo);
self.refresh_layer_panel();
self.command_line.push_info("Layer(s) locked.");
}
@ -359,41 +373,43 @@ impl OpenCADStudio {
}
"LAYON" => {
self.push_undo_snapshot(i, "LAYON");
for name in self.tabs[i]
let names = self.tabs[i]
.scene
.document
.layers
.iter()
.map(|l| l.name.clone())
.collect::<Vec<_>>()
{
.collect::<Vec<_>>();
let undo = self.begin_layer_undo(i, "LAYON", &names);
for name in &names {
if let Some(dl) = self.tabs[i].scene.document.layers.get_mut(&name) {
dl.turn_on();
}
}
self.tabs[i].scene.bump_geometry();
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_info("All layers turned on.");
}
"LAYTHW" => {
self.push_undo_snapshot(i, "LAYTHW");
for name in self.tabs[i]
let names = self.tabs[i]
.scene
.document
.layers
.iter()
.map(|l| l.name.clone())
.collect::<Vec<_>>()
{
.collect::<Vec<_>>();
let undo = self.begin_layer_undo(i, "LAYTHW", &names);
for name in &names {
if let Some(dl) = self.tabs[i].scene.document.layers.get_mut(&name) {
dl.thaw();
}
}
self.tabs[i].scene.bump_geometry();
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_info("All layers thawed.");
}
@ -417,14 +433,16 @@ impl OpenCADStudio {
.into_iter()
.map(|(_, e)| e.common().layer.clone())
.collect();
self.push_undo_snapshot(i, "LAYULK");
let names: Vec<String> = layers.iter().cloned().collect();
let undo = self.begin_layer_undo(i, "LAYULK", &names);
for name in &layers {
if let Some(dl) = self.tabs[i].scene.document.layers.get_mut(name) {
dl.unlock();
}
}
self.tabs[i].scene.bump_geometry();
// Layer unlocking changes editability only.
self.tabs[i].dirty = true;
self.commit_layer_undo(i, undo);
self.refresh_layer_panel();
self.command_line.push_info("Layer(s) unlocked.");
}
@ -442,7 +460,6 @@ impl OpenCADStudio {
self.command_line
.push_error("LAYISO: select entities on the layers to isolate first.");
} else {
self.push_undo_snapshot(i, "LAYISO");
let names: Vec<String> = self.tabs[i]
.scene
.document
@ -450,15 +467,17 @@ impl OpenCADStudio {
.iter()
.map(|l| l.name.clone())
.collect();
for name in names {
if !sel_layers.contains(&name) {
if let Some(dl) = self.tabs[i].scene.document.layers.get_mut(&name) {
let undo = self.begin_layer_undo(i, "LAYISO", &names);
for name in &names {
if !sel_layers.contains(name) {
if let Some(dl) = self.tabs[i].scene.document.layers.get_mut(name) {
dl.turn_off();
}
}
}
self.tabs[i].scene.bump_geometry();
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_info(&format!("LAYISO: isolated {} layer(s).", sel_layers.len()));
@ -511,7 +530,6 @@ impl OpenCADStudio {
// LAYUNISO — restore all layers that were turned off by LAYISO (turn all on)
"LAYUNISO" => {
self.push_undo_snapshot(i, "LAYUNISO");
let names: Vec<String> = self.tabs[i]
.scene
.document
@ -519,13 +537,15 @@ impl OpenCADStudio {
.iter()
.map(|l| l.name.clone())
.collect();
for name in names {
let undo = self.begin_layer_undo(i, "LAYUNISO", &names);
for name in &names {
if let Some(dl) = self.tabs[i].scene.document.layers.get_mut(&name) {
dl.turn_on();
}
}
self.tabs[i].scene.bump_geometry();
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_info("LAYUNISO: all layers restored.");
@ -587,9 +607,10 @@ impl OpenCADStudio {
self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd));
} else {
self.push_undo_snapshot(i, "UNGROUP");
let undo = self.begin_group_undo(i, "UNGROUP");
let count = self.tabs[i].scene.delete_groups_containing(&handles);
self.tabs[i].dirty = true;
self.commit_group_undo(i, undo);
if count > 0 {
self.command_line
.push_info(&format!("{} group(s) dissolved.", count));

View file

@ -520,7 +520,7 @@ impl HistorySnapshot {
.saturating_add(
d.structure
.as_ref()
.map_or(0, |doc| doc.objects.len().saturating_mul(192)),
.map_or(0, StructureSnapshot::estimated_bytes),
)
.saturating_add(d.selected_before.len().saturating_mul(16))
.saturating_add(d.selected_after.len().saturating_mul(16))
@ -546,10 +546,67 @@ pub(super) struct DeltaSnapshot {
pub(super) dirty_after: bool,
/// 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<CadDocument>,
pub(super) structure: Option<StructureSnapshot>,
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<acadrust::tables::Layer>>),
/// Exact text-style entries touched by one command.
TextStyles(Vec<TableEntryDelta<acadrust::tables::TextStyle>>),
/// Exact dimension-style entries touched by one command.
DimStyles(Vec<TableEntryDelta<acadrust::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<acadrust::objects::ObjectType>,
pub(super) after: Option<acadrust::objects::ObjectType>,
}
#[derive(Default)]
pub(super) struct HistoryState {
pub(super) undo_stack: Vec<HistorySnapshot>,

View file

@ -1,5 +1,8 @@
use super::{
document::{DeltaSnapshot, HistorySnapshot, PendingHistorySnapshot},
document::{
DeltaSnapshot, HistorySnapshot, ObjectEntryDelta, PendingHistorySnapshot,
StructureSnapshot, TableEntryDelta,
},
OpenCADStudio,
};
use acadrust::{EntityType, Handle};
@ -71,6 +74,38 @@ pub(super) struct PendingDelta {
structure_before: Option<acadrust::CadDocument>,
}
pub(super) struct PendingLayerDelta {
label: String,
current_layout: String,
selected_before: Vec<Handle>,
dirty_before: bool,
before: Vec<(String, Option<acadrust::tables::Layer>)>,
}
pub(super) struct PendingTextStyleDelta {
label: String,
current_layout: String,
selected_before: Vec<Handle>,
dirty_before: bool,
before: Vec<(String, Option<acadrust::tables::TextStyle>)>,
}
pub(super) struct PendingDimStyleDelta {
label: String,
current_layout: String,
selected_before: Vec<Handle>,
dirty_before: bool,
before: Vec<(String, Option<acadrust::tables::DimStyle>)>,
}
pub(super) struct PendingObjectDelta {
label: String,
current_layout: String,
selected_before: Vec<Handle>,
dirty_before: bool,
before: FxHashMap<Handle, acadrust::objects::ObjectType>,
}
impl OpenCADStudio {
pub(super) fn history_label_from_active_cmd(&self, i: usize, fallback: &'static str) -> String {
self.tabs[i]
@ -248,7 +283,7 @@ impl OpenCADStudio {
selected_after,
dirty_before: pending.dirty_before,
dirty_after,
structure: structure_changed.then_some(pending.structure_before),
structure: structure_changed.then_some(StructureSnapshot::Full(pending.structure_before)),
label: pending.label,
};
self.push_undo_entry(i, HistorySnapshot::Delta(delta));
@ -304,6 +339,263 @@ impl OpenCADStudio {
})
}
pub(super) fn begin_layer_undo(
&mut self,
i: usize,
label: impl Into<String>,
names: &[String],
) -> PendingLayerDelta {
self.finish_pending_history(i);
PendingLayerDelta {
label: label.into(),
current_layout: self.tabs[i].scene.current_layout.clone(),
selected_before: self.tabs[i].scene.selected.iter().copied().collect(),
dirty_before: self.tabs[i].dirty,
before: names
.iter()
.map(|name| {
(
name.clone(),
self.tabs[i].scene.document.layers.get(name).cloned(),
)
})
.collect(),
}
}
pub(super) fn commit_layer_undo(&mut self, i: usize, pending: PendingLayerDelta) {
let entries: Vec<_> = pending
.before
.into_iter()
.filter_map(|(name, before)| {
let after = self.tabs[i].scene.document.layers.get(&name).cloned();
(before != after).then_some(TableEntryDelta {
name,
before,
after,
})
})
.collect();
if entries.is_empty() {
return;
}
let selected_after = self.tabs[i].scene.selected.iter().copied().collect();
let delta = DeltaSnapshot {
entities: Vec::new(),
current_layout_before: pending.current_layout,
current_layout_after: self.tabs[i].scene.current_layout.clone(),
selected_before: pending.selected_before,
selected_after,
dirty_before: pending.dirty_before,
dirty_after: self.tabs[i].dirty,
structure: Some(StructureSnapshot::Layers(entries)),
label: pending.label,
};
self.push_undo_entry(i, HistorySnapshot::Delta(delta));
}
pub(super) fn begin_text_style_undo(
&mut self,
i: usize,
label: impl Into<String>,
names: &[String],
) -> PendingTextStyleDelta {
self.finish_pending_history(i);
PendingTextStyleDelta {
label: label.into(),
current_layout: self.tabs[i].scene.current_layout.clone(),
selected_before: self.tabs[i].scene.selected.iter().copied().collect(),
dirty_before: self.tabs[i].dirty,
before: names
.iter()
.map(|name| {
(
name.clone(),
self.tabs[i].scene.document.text_styles.get(name).cloned(),
)
})
.collect(),
}
}
pub(super) fn commit_text_style_undo(&mut self, i: usize, pending: PendingTextStyleDelta) {
let entries: Vec<_> = pending
.before
.into_iter()
.filter_map(|(name, before)| {
let after = self.tabs[i].scene.document.text_styles.get(&name).cloned();
(before != after).then_some(TableEntryDelta {
name,
before,
after,
})
})
.collect();
if entries.is_empty() {
return;
}
let delta = DeltaSnapshot {
entities: Vec::new(),
current_layout_before: pending.current_layout,
current_layout_after: self.tabs[i].scene.current_layout.clone(),
selected_before: pending.selected_before,
selected_after: self.tabs[i].scene.selected.iter().copied().collect(),
dirty_before: pending.dirty_before,
dirty_after: self.tabs[i].dirty,
structure: Some(StructureSnapshot::TextStyles(entries)),
label: pending.label,
};
self.push_undo_entry(i, HistorySnapshot::Delta(delta));
}
pub(super) fn begin_dim_style_undo(
&mut self,
i: usize,
label: impl Into<String>,
names: &[String],
) -> PendingDimStyleDelta {
self.finish_pending_history(i);
PendingDimStyleDelta {
label: label.into(),
current_layout: self.tabs[i].scene.current_layout.clone(),
selected_before: self.tabs[i].scene.selected.iter().copied().collect(),
dirty_before: self.tabs[i].dirty,
before: names
.iter()
.map(|name| {
(
name.clone(),
self.tabs[i].scene.document.dim_styles.get(name).cloned(),
)
})
.collect(),
}
}
pub(super) fn commit_dim_style_undo(&mut self, i: usize, pending: PendingDimStyleDelta) {
let entries: Vec<_> = pending
.before
.into_iter()
.filter_map(|(name, before)| {
let after = self.tabs[i].scene.document.dim_styles.get(&name).cloned();
(before != after).then_some(TableEntryDelta {
name,
before,
after,
})
})
.collect();
if entries.is_empty() {
return;
}
let delta = DeltaSnapshot {
entities: Vec::new(),
current_layout_before: pending.current_layout,
current_layout_after: self.tabs[i].scene.current_layout.clone(),
selected_before: pending.selected_before,
selected_after: self.tabs[i].scene.selected.iter().copied().collect(),
dirty_before: pending.dirty_before,
dirty_after: self.tabs[i].dirty,
structure: Some(StructureSnapshot::DimStyles(entries)),
label: pending.label,
};
self.push_undo_entry(i, HistorySnapshot::Delta(delta));
}
fn group_object_state(&self, i: usize) -> FxHashMap<Handle, acadrust::objects::ObjectType> {
use acadrust::objects::ObjectType;
let document = &self.tabs[i].scene.document;
let dictionary = document.header.acad_group_dict_handle;
document
.objects
.iter()
.filter(|(handle, object)| {
**handle == dictionary || matches!(object, ObjectType::Group(_))
})
.map(|(handle, object)| (*handle, object.clone()))
.collect()
}
pub(super) fn begin_group_undo(
&mut self,
i: usize,
label: impl Into<String>,
) -> PendingObjectDelta {
self.finish_pending_history(i);
PendingObjectDelta {
label: label.into(),
current_layout: self.tabs[i].scene.current_layout.clone(),
selected_before: self.tabs[i].scene.selected.iter().copied().collect(),
dirty_before: self.tabs[i].dirty,
before: self.group_object_state(i),
}
}
pub(super) fn commit_group_undo(&mut self, i: usize, pending: PendingObjectDelta) {
let after = self.group_object_state(i);
let mut handles: HashSet<Handle> = pending.before.keys().copied().collect();
handles.extend(after.keys().copied());
let entries: Vec<_> = handles
.into_iter()
.filter_map(|handle| {
let before = pending.before.get(&handle).cloned();
let after = after.get(&handle).cloned();
(before != after).then_some(ObjectEntryDelta {
handle,
before,
after,
})
})
.collect();
if entries.is_empty() {
return;
}
let delta = DeltaSnapshot {
entities: Vec::new(),
current_layout_before: pending.current_layout,
current_layout_after: self.tabs[i].scene.current_layout.clone(),
selected_before: pending.selected_before,
selected_after: self.tabs[i].scene.selected.iter().copied().collect(),
dirty_before: pending.dirty_before,
dirty_after: self.tabs[i].dirty,
structure: Some(StructureSnapshot::Objects(entries)),
label: pending.label,
};
self.push_undo_entry(i, HistorySnapshot::Delta(delta));
}
pub(super) fn commit_style_undo(
&mut self,
i: usize,
before: super::style_ops::StyleStateSnapshot,
after: super::style_ops::StyleStateSnapshot,
dirty_before: bool,
) {
if before == after {
return;
}
self.finish_pending_history(i);
let (text_names, dim_names, object_handles) = after.changed_keys(&before);
let delta = DeltaSnapshot {
entities: Vec::new(),
current_layout_before: self.tabs[i].scene.current_layout.clone(),
current_layout_after: self.tabs[i].scene.current_layout.clone(),
selected_before: self.tabs[i].scene.selected.iter().copied().collect(),
selected_after: self.tabs[i].scene.selected.iter().copied().collect(),
dirty_before,
dirty_after: true,
structure: Some(StructureSnapshot::Styles {
before,
after,
text_names,
dim_names,
object_handles,
}),
label: "STYLE".to_string(),
};
self.push_undo_entry(i, HistorySnapshot::Delta(delta));
}
/// Copy is delta-safe only when no target is a Dimension and no complete
/// group is copied: dimensions clone fresh anonymous `*D` block records,
/// and complete group copies add Group objects / dictionary entries. Both
@ -397,11 +689,12 @@ impl OpenCADStudio {
.collect();
let selected_after = self.tabs[i].scene.selected.iter().copied().collect();
let dirty_after = self.tabs[i].dirty;
let mut structure = pending.structure_before;
let mut structure = pending.structure_before.map(StructureSnapshot::Full);
if let Some(before_structure) = structure.as_mut() {
let after_structure = self.tabs[i].scene.document.snapshot_structure();
let added_handles: Vec<Handle> = entities
.iter()
if let StructureSnapshot::Full(before_structure) = before_structure {
let after_structure = self.tabs[i].scene.document.snapshot_structure();
let added_handles: Vec<Handle> = entities
.iter()
.filter_map(|(handle, before, after)| {
(before.is_none() && after.is_some()).then_some(*handle)
})
@ -414,6 +707,7 @@ impl OpenCADStudio {
if *before_structure == after_structure {
structure = None;
}
}
}
let delta = DeltaSnapshot {
entities,
@ -443,9 +737,113 @@ impl OpenCADStudio {
// Install the chosen side of every entity image. Derived-cache, geometry
// and UI invalidation are deferred until every requested undo/redo step
// has been applied.
if let Some(structure) = d.structure.take() {
let inverse = self.tabs[i].scene.document.swap_structure(structure);
d.structure = Some(inverse);
if let Some(structure) = d.structure.as_mut() {
match structure {
StructureSnapshot::Full(stored) => {
let inverse = self.tabs[i].scene.document.swap_structure(
std::mem::replace(stored, acadrust::CadDocument::new()),
);
*stored = inverse;
self.tabs[i].scene.invalidate_dependency_index();
}
StructureSnapshot::Layers(entries) => {
let names: Vec<String> =
entries.iter().map(|entry| entry.name.clone()).collect();
for entry in entries {
let value = if undo {
entry.before.clone()
} else {
entry.after.clone()
};
if let Some(layer) = value {
self.tabs[i].scene.document.layers.add_or_replace(layer);
} else {
self.tabs[i].scene.document.layers.remove(&entry.name);
}
}
self.tabs[i].scene.invalidate_layer_dependencies(&names);
}
StructureSnapshot::TextStyles(entries) => {
let names: Vec<String> =
entries.iter().map(|entry| entry.name.clone()).collect();
for entry in entries {
let value = if undo {
entry.before.clone()
} else {
entry.after.clone()
};
if let Some(style) = value {
self.tabs[i]
.scene
.document
.text_styles
.add_or_replace(style);
} else {
self.tabs[i].scene.document.text_styles.remove(&entry.name);
}
}
for name in names {
self.tabs[i].scene.invalidate_text_style_dependencies(&name);
}
}
StructureSnapshot::DimStyles(entries) => {
let names: Vec<String> =
entries.iter().map(|entry| entry.name.clone()).collect();
for entry in entries {
let value = if undo {
entry.before.clone()
} else {
entry.after.clone()
};
if let Some(style) = value {
self.tabs[i].scene.document.dim_styles.add_or_replace(style);
} else {
self.tabs[i].scene.document.dim_styles.remove(&entry.name);
}
}
for name in names {
self.tabs[i].scene.invalidate_dim_style_dependencies(&name);
}
}
StructureSnapshot::Objects(entries) => {
for entry in entries {
let value = if undo {
entry.before.clone()
} else {
entry.after.clone()
};
if let Some(object) = value {
self.tabs[i]
.scene
.document
.objects
.insert(entry.handle, object);
} else {
self.tabs[i].scene.document.objects.remove(&entry.handle);
}
}
self.tabs[i].scene.invalidate_dependency_index();
}
StructureSnapshot::Styles {
before,
after,
text_names,
dim_names,
object_handles,
} => {
let snapshot = if undo { before } else { after };
self.restore_style_state(i, snapshot);
self.tabs[i]
.scene
.invalidate_text_style_dependencies_many(text_names);
self.tabs[i]
.scene
.invalidate_dim_style_dependencies_many(dim_names);
self.tabs[i]
.scene
.invalidate_object_style_dependencies(object_handles);
}
}
}
let changes = self.tabs[i].scene.apply_entity_delta(&d.entities, undo);
let scene = &mut self.tabs[i].scene;
@ -479,6 +877,7 @@ impl OpenCADStudio {
&mut self,
i: usize,
had_full: bool,
structure_changed: bool,
changes: &[(Handle, crate::scene::ChangeKind)],
) {
self.tabs[i].edit_revision = self.tabs[i].edit_revision.wrapping_add(1);
@ -524,7 +923,7 @@ impl OpenCADStudio {
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.tabs[i].active_grip = None;
if had_full {
if structure_changed {
let doc_layers = self.tabs[i].scene.document.layers.clone();
let vp_info = self.tabs[i].scene.viewport_list();
self.tabs[i]
@ -555,6 +954,7 @@ impl OpenCADStudio {
let mut last_label = String::new();
let mut had_full = false;
let mut structure_changed = false;
let mut changes = Vec::new();
for _ in 0..steps {
let Some(snapshot) = self.tabs[i].history.undo_stack.pop() else {
@ -566,7 +966,8 @@ impl OpenCADStudio {
// Symmetric: undo applies the before side, then the same
// delta rides to the redo stack (it still holds the after
// side) — no current-state capture needed.
had_full |= d.structure.is_some();
structure_changed |= d.structure.is_some();
had_full |= d.structure.as_ref().is_some_and(StructureSnapshot::is_full);
changes.extend(self.apply_delta_state(i, &mut d, true));
self.tabs[i]
.history
@ -575,7 +976,7 @@ impl OpenCADStudio {
}
}
}
self.finish_history_apply(i, had_full, &changes);
self.finish_history_apply(i, had_full, structure_changed, &changes);
self.command_line
.push_output(&format!("Undo: {last_label}"));
}
@ -592,6 +993,7 @@ impl OpenCADStudio {
let mut last_label = String::new();
let mut had_full = false;
let mut structure_changed = false;
let mut changes = Vec::new();
for _ in 0..steps {
let Some(snapshot) = self.tabs[i].history.redo_stack.pop() else {
@ -600,7 +1002,8 @@ impl OpenCADStudio {
last_label = snapshot.label().to_string();
match snapshot {
HistorySnapshot::Delta(mut d) => {
had_full |= d.structure.is_some();
structure_changed |= d.structure.is_some();
had_full |= d.structure.as_ref().is_some_and(StructureSnapshot::is_full);
changes.extend(self.apply_delta_state(i, &mut d, false));
self.tabs[i]
.history
@ -609,7 +1012,7 @@ impl OpenCADStudio {
}
}
}
self.finish_history_apply(i, had_full, &changes);
self.finish_history_apply(i, had_full, structure_changed, &changes);
self.command_line
.push_output(&format!("Redo: {last_label}"));
}

View file

@ -168,7 +168,6 @@ use acadrust::CadDocument;
use iced::time::Instant;
use iced::window;
use iced::{mouse, Point, Task, Theme};
use std::sync::atomic::AtomicU8;
use std::sync::Arc;
pub(super) const POLY_START_DELAY_MS: u128 = 150;
@ -179,14 +178,15 @@ pub(super) const VARIES_LABEL: &str = "*VARIES*";
// loader thread, read by the UI overlay on every frame.
pub const OPEN_PHASE_READING: u8 = 0;
pub const OPEN_PHASE_PARSING: u8 = 1;
pub const OPEN_PHASE_CACHING: u8 = 2;
pub const OPEN_PHASE_FINALIZING: u8 = 3;
pub const OPEN_PHASE_XREF: u8 = 2;
pub const OPEN_PHASE_CACHING: u8 = 3;
pub const OPEN_PHASE_FINALIZING: u8 = 4;
#[derive(Debug, Clone)]
pub struct OpenProgress {
pub name: String,
pub size_bytes: u64,
pub phase: Arc<AtomicU8>,
pub state: Arc<crate::io::OpenProgressState>,
pub started: Instant,
}
@ -2098,6 +2098,9 @@ pub enum Message {
PlotWindowExport,
/// Callback after the user picks (or cancels) the window-export path.
PlotWindowExportPath(Option<std::path::PathBuf>),
/// Completion of a PDF/preview/print job performed outside the UI thread.
/// The boolean restores the Plot dialog after a preview.
BackgroundIoFinished(Result<String, String>, bool),
/// Send current layout to the system printer (via lp / lpr).
PrintToPrinter,
/// Callback from the async printer job.
@ -2299,6 +2302,8 @@ pub enum Message {
WblockSave(String),
/// Result of the WBLOCK save path dialog.
WblockSaveResult(String, Option<std::path::PathBuf>),
/// Background extraction/write completion.
WblockWriteFinished(String, std::path::PathBuf, Result<(), String>),
// ── DATAEXTRACTION ────────────────────────────────────────────────────
/// Save the pre-built CSV string to a file chosen by the user.
DataExtractionSave(String),
@ -2309,16 +2314,23 @@ pub enum Message {
StlExport,
/// Callback after the user picks (or cancels) the STL save path.
StlExportPath(Option<std::path::PathBuf>),
StlExportFinished(std::path::PathBuf, Result<(), String>),
// ── STEP export ───────────────────────────────────────────────────────
/// Trigger STEP AP203 export: show save dialog.
StepExport,
/// Callback after the user picks (or cancels) the STEP save path.
StepExportPath(Option<std::path::PathBuf>),
StepExportFinished(std::path::PathBuf, Result<(), String>),
// ── OBJ import ────────────────────────────────────────────────────────
/// Trigger OBJ import: show open-file dialog.
ObjImport,
/// Callback after the user picks (or cancels) the OBJ file path.
ObjImportPath(Option<std::path::PathBuf>),
ObjImportFinished(
u64,
std::path::PathBuf,
Result<crate::scene::model::mesh_model::MeshModel, String>,
),
}
impl OpenCADStudio {

View file

@ -539,8 +539,7 @@ impl OpenCADStudio {
/// Overwrite the live style state with a snapshot (used by commit's undo
/// dance and by discard).
fn restore_style_state(&mut self, snap: &StyleStateSnapshot) {
let i = self.active_tab;
pub(super) fn restore_style_state(&mut self, i: usize, snap: &StyleStateSnapshot) {
let doc = &mut self.tabs[i].scene.document;
doc.text_styles = snap.text_styles.clone();
doc.dim_styles = snap.dim_styles.clone();
@ -560,8 +559,10 @@ impl OpenCADStudio {
doc.header.multiline_style = snap.multiline_style.clone();
doc.header.current_table_style_name = snap.current_table.clone();
doc.header.current_mleader_style_name = snap.current_mleader.clone();
self.ribbon.active_table_style = snap.active_table.clone();
self.ribbon.active_mleader_style = snap.active_mleader.clone();
if i == self.active_tab {
self.ribbon.active_table_style = snap.active_table.clone();
self.ribbon.active_mleader_style = snap.active_mleader.clone();
}
self.tabs[i].active_mleader_style = snap.tab_active_mleader.clone();
}
@ -587,18 +588,26 @@ impl OpenCADStudio {
self.sync_ribbon_styles();
return;
};
// Capture the edited state, rewind to the baseline so the undo entry
// restores the pre-edit document, then re-apply the edits on top.
let edited = self.capture_style_state();
self.restore_style_state(&stage.baseline);
self.push_undo_snapshot(i, "STYLE");
self.restore_style_state(&edited);
self.tabs[i].dirty = true;
self.tabs[i].scene.bump_geometry();
let changed = edited != stage.baseline;
if changed {
self.tabs[i].dirty = true;
let (text_names, dim_names, object_handles) = edited.changed_keys(&stage.baseline);
self.tabs[i].scene.invalidate_text_style_dependencies_many(&text_names);
self.tabs[i]
.scene
.invalidate_dim_style_dependencies_many(&dim_names);
self.tabs[i]
.scene
.invalidate_object_style_dependencies(&object_handles);
self.commit_style_undo(i, stage.baseline, edited.clone(), stage.dirty_at_open);
} else {
self.tabs[i].dirty = stage.dirty_at_open;
}
self.sync_ribbon_styles();
// Re-baseline so further edits in the still-open window stage afresh.
self.style_stage = Some(StyleStage {
dirty_at_open: true,
dirty_at_open: self.tabs[i].dirty,
baseline: edited,
});
}
@ -609,13 +618,14 @@ impl OpenCADStudio {
let Some(stage) = self.style_stage.take() else {
return;
};
self.restore_style_state(&stage.baseline);
self.restore_style_state(self.active_tab, &stage.baseline);
self.tabs[self.active_tab].dirty = stage.dirty_at_open;
self.sync_ribbon_styles();
}
}
/// Snapshot of every document field a style manager can touch.
#[derive(Clone, PartialEq)]
pub(super) struct StyleStateSnapshot {
text_styles: acadrust::tables::Table<TextStyle>,
dim_styles: acadrust::tables::Table<DimStyle>,
@ -630,6 +640,64 @@ pub(super) struct StyleStateSnapshot {
tab_active_mleader: String,
}
impl StyleStateSnapshot {
pub(super) fn estimated_bytes(&self) -> usize {
self.text_styles
.iter()
.count()
.saturating_mul(320)
.saturating_add(self.dim_styles.iter().count().saturating_mul(1024))
.saturating_add(self.style_objects.len().saturating_mul(512))
}
pub(super) fn changed_keys(&self, other: &Self) -> (Vec<String>, Vec<String>, Vec<Handle>) {
let text_names = self
.text_styles
.iter()
.filter(|style| other.text_styles.get(&style.name) != Some(*style))
.map(|style| style.name.clone())
.chain(
other
.text_styles
.iter()
.filter(|style| self.text_styles.get(&style.name) != Some(*style))
.map(|style| style.name.clone()),
)
.collect::<rustc_hash::FxHashSet<_>>()
.into_iter()
.collect();
let dim_names = self
.dim_styles
.iter()
.filter(|style| other.dim_styles.get(&style.name) != Some(*style))
.map(|style| style.name.clone())
.chain(
other
.dim_styles
.iter()
.filter(|style| self.dim_styles.get(&style.name) != Some(*style))
.map(|style| style.name.clone()),
)
.collect::<rustc_hash::FxHashSet<_>>()
.into_iter()
.collect();
let self_objects: rustc_hash::FxHashMap<_, _> =
self.style_objects.iter().map(|(h, o)| (*h, o)).collect();
let other_objects: rustc_hash::FxHashMap<_, _> =
other.style_objects.iter().map(|(h, o)| (*h, o)).collect();
let object_handles = self
.style_objects
.iter()
.chain(other.style_objects.iter())
.map(|(handle, _)| *handle)
.collect::<rustc_hash::FxHashSet<_>>()
.into_iter()
.filter(|handle| self_objects.get(handle) != other_objects.get(handle))
.collect();
(text_names, dim_names, object_handles)
}
}
/// An in-progress style-manager transaction.
pub(super) struct StyleStage {
dirty_at_open: bool,

View file

@ -20,6 +20,28 @@ use acadrust::{EntityType as AcadEntityType, Handle};
use iced::time::Instant;
use iced::{mouse, Point, Task};
pub(super) fn background_task<T, F, M>(work: F, map: M) -> Task<Message>
where
T: Send + 'static,
F: FnOnce() -> T + Send + 'static,
M: FnOnce(T) -> Message + Send + 'static,
{
#[cfg(not(target_arch = "wasm32"))]
{
let (tx, rx) = iced::futures::channel::oneshot::channel();
std::thread::spawn(move || {
let _ = tx.send(work());
});
Task::perform(
async move { rx.await.expect("background export worker dropped") },
map,
)
}
#[cfg(target_arch = "wasm32")]
{
Task::perform(async move { work() }, map)
}
}
impl OpenCADStudio {
/// Before a save, give every cached truck solid that still has no ACIS
@ -362,15 +384,16 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
// 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.
let state = std::sync::Arc::new(crate::io::OpenProgressState::new(
crate::app::OPEN_PHASE_READING,
));
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,
)),
state: state.clone(),
started: Instant::now(),
});
Task::perform(crate::io::pick_and_load_web(), Message::FileOpened)
Task::perform(crate::io::pick_and_load_web(state), Message::FileOpened)
}
}
@ -403,13 +426,15 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
}
}
pub(super) fn on_file_opened(&mut self, name: String, path: std::path::PathBuf, doc: acadrust::CadDocument, caches: crate::scene::DerivedCaches) -> Task<Message> {
pub(super) fn on_file_opened(&mut self, name: String, path: std::path::PathBuf, doc: acadrust::CadDocument,
mut 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 open_started = self.opening.as_ref().map(|p| p.started);
let timings = caches.timings;
let entity_count = doc.entities().count();
self.command_line
@ -420,6 +445,30 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
caches.corrupt_dropped
));
}
if caches.xref_dropped > 0 {
self.command_line.push_error(&format!(
"Warning: {} corrupt xref entities dropped",
caches.xref_dropped
));
}
for info in &caches.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));
}
}
}
let thumbs_task = self.push_recent(path.clone());
let current_is_empty = {
@ -472,47 +521,6 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
1.0
};
// Auto-resolve XREFs relative to the opened file's directory.
let mut xref_ms = 0u32;
let mut xref_merged = false;
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 => {
xref_merged = true;
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
@ -522,7 +530,7 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
.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
timings.parse_ms, timings.purge_ms, timings.caches_ms, timings.xref_ms, total_ms
));
// Caches were built on the background thread inside open_path().
@ -532,18 +540,11 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
self.tabs[i].scene.images = caches.images;
self.tabs[i].scene.meshes = caches.meshes;
self.tabs[i].scene.block_meshes = caches.block_meshes;
let prepared_geometry = caches.prepared_geometry.take();
// Invalidate the wire cache so the new document is tessellated.
self.tabs[i].scene.bump_geometry();
// XREFs are merged into the document AFTER the background worker
// built the mesh caches above, so those caches contain none of
// the xref'd geometry. The wire pass rebuilds from the document
// each frame (bump_geometry covers it), but 3D-solid meshes are
// only tessellated by populate — run the incremental variant so
// the already-cached host solids are kept and only the newly
// merged xref solids (walls, floors, roofs) are tessellated,
// avoiding a full re-tessellation of the whole drawing. (#203)
if xref_merged {
self.tabs[i].scene.populate_missing_meshes_from_document();
if let Some(prepared) = prepared_geometry {
self.tabs[i].scene.install_prepared_open_geometry(prepared);
}
self.tabs[i].scene.selected = rustc_hash::FxHashSet::default();
self.tabs[i].scene.preview_wires = vec![];
@ -625,42 +626,38 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
};
#[cfg(target_arch = "wasm32")]
let interaction_task = Task::none();
if let Some(opening) = &self.opening {
opening
.state
.set(crate::app::OPEN_PHASE_FINALIZING, 10000, 1, 1);
}
self.opening.take();
let pending_open_task = self.drain_pending_open();
Task::batch([thumbs_task, pending_open_task, interaction_task])
}
pub(super) fn on_wblock_save_result_some(&mut self, block_name: String, path: std::path::PathBuf) -> Task<Message> {
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 document = self.tabs[i].scene.document.clone();
let handles: Vec<_> = self.tabs[i].scene.selected.iter().copied().collect();
let worker_name = block_name.clone();
let worker_path = path.clone();
background_task(
move || {
let document = if worker_name == "*" {
crate::modules::insert::wblock::extract_entities_to_doc(
&self.tabs[i].scene.document,
&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}")),
&document,
&worker_name)
}
Task::none()
.map_err(|e| e.to_string())?; crate::io::save(&document, &worker_path).map_err(|e| e.to_string())
},
move |result| Message::WblockWriteFinished(block_name, path, result),)
}
pub(super) fn on_stl_export_path_some(&mut self, path: std::path::PathBuf) -> Task<Message> {
@ -675,21 +672,15 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
.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}")),
let worker_path = path.clone();
background_task(
move || {
let mesh_refs: Vec<_> = meshes.iter().collect();
let bytes = crate::io::stl::build_stl(&mesh_refs)
.ok_or_else(|| "no mesh data to export".to_string())?;
std::fs::write(&worker_path, bytes).map_err(|e| e.to_string())
},
None => self
.command_line
.push_error("STLOUT: no mesh data to export."),
}
Task::none()
move |result| Message::StlExportFinished(path, result),)
}
pub(super) fn on_step_export_path_some(&mut self, path: std::path::PathBuf) -> Task<Message> {
@ -701,63 +692,26 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
.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}")),
let worker_path = path.clone();
background_task(
move || {
let mesh_refs: Vec<_> = meshes.iter().collect();
let text = crate::io::step::build_step(&mesh_refs)
.ok_or_else(|| "no mesh data to export".to_string())?;
std::fs::write(&worker_path, text.as_bytes()).map_err(|e| e.to_string())
},
None => self
.command_line
.push_error("STEPOUT: no mesh data to export."),
}
Task::none()
move |result| Message::StepExportFinished(path, result),)
}
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()
let tab_id = self.tabs[self.active_tab].id;
let worker_path = path.clone();
background_task(
move || {
let src = std::fs::read_to_string(&worker_path).map_err(|e| e.to_string())?; crate::io::obj::parse_obj(&src, [0.7, 0.7, 0.85, 1.0])
.ok_or_else(|| "no usable geometry in file".to_string())
},
move |result| Message::ObjImportFinished(tab_id, path, result),)
}
#[cfg(not(target_arch = "wasm32"))]
@ -770,6 +724,19 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
self.sync_truck_solids_to_acis(i);
}
#[cfg(target_arch = "wasm32")]
fn stamp_thumbnail(&mut self, i: usize, version: acadrust::DxfVersion) {
let scene = &self.tabs[i].scene;
let preview = crate::io::thumbnail::from_snapshot(
&scene.entity_wires(),
&scene.camera.borrow(),
scene.bg_color,
version >= acadrust::DxfVersion::AC1027,
self.vp_size,
);
self.tabs[i].scene.document.preview = preview;
}
#[cfg(not(target_arch = "wasm32"))]
pub(in crate::app) fn queue_native_save(
&mut self,
@ -1484,7 +1451,11 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
_ => (paper_w, paper_h),
};
match crate::io::pdf_export::export_pdf(
let plot_style = self.active_plot_style.clone();
let worker_path = path.clone();
background_task(
move || {
crate::io::pdf_export::export_pdf(
&wires,
hatches.as_slice(),
wipeouts.as_slice(),
@ -1495,23 +1466,20 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
rotation_deg,
1.0,
None,
&path,
self.active_plot_style.as_ref(),
) {
// Full path, not just the file name — when the export was
// driven by EXPORTPDF <path> the user needs to see where
// the file actually landed. (#369)
Ok(()) => self
.command_line
.push_info(&format!("Exported: {}", path.display())),
Err(e) => self.command_line.push_error(&format!("Export failed: {e}")),
}
Task::none()
&worker_path,
plot_style.as_ref(),
)
.map(|_| format!("Exported: {}", worker_path.display()))
.map_err(|e| format!("Export failed: {e}"))
},
|result| Message::BackgroundIoFinished(result, false),
)
}
/// Export the pending model-space plot window (set by PLOTWINDOW while on
/// the Model tab) to PDF, using the chosen paper size/orientation/scale.
pub(super) fn on_plot_window_export_path_some(&mut self, path: std::path::PathBuf) -> Task<Message> {
pub(super) fn on_plot_window_export_path_some(&mut self, path: std::path::PathBuf,
) -> Task<Message> {
use crate::io::paper_sizes::{sheet_mm, window_to_sheet, PlotScale};
let i = self.active_tab;
if self.tabs[i].scene.current_layout != "Model" {
@ -1569,7 +1537,12 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
// same as the wires) so the final sheet-mm rect lands at (ox, oy).
let clip = Some(((ox / scale) as f32, (oy / scale) as f32, win_w as f32, win_h as f32));
let res = crate::io::pdf_export::export_pdf(
let plot_style = self.active_plot_style.clone();
let worker_path = path.clone();
self.close_active_modal();
background_task(
move || {
crate::io::pdf_export::export_pdf(
&wires,
hatches.as_slice(),
wipeouts.as_slice(),
@ -1580,20 +1553,18 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
0,
scale as f32,
clip,
&path,
self.active_plot_style.as_ref(),
);
match res {
Ok(()) => {
self.command_line.push_info(&format!(
&worker_path,
plot_style.as_ref(),
)
.map(|_| {format!(
"Plotted window to {}",
path.file_name().unwrap_or_default().to_string_lossy()
));
self.close_active_modal();
}
Err(e) => self.command_line.push_error(&format!("Plot failed: {e}")),
}
Task::none()
worker_path
.file_name().unwrap_or_default().to_string_lossy()
)
}).map_err(|e| format!("Plot failed: {e}"))
},
|result| Message::BackgroundIoFinished(result, false),
)
}
/// Build the render inputs and page geometry for a full-layout plot: wires
@ -1678,12 +1649,12 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
self.layout_plot_params();
let plot_style = self.active_plot_style.clone();
self.command_line.push_info("Sending to system printer…");
Task::perform(
async move {
background_task(
move || {
iced::futures::executor::block_on(
crate::io::print_to_printer::print_wires(
wires, hatches, wipeouts, eff_w, eff_h, ox, oy, rotation_deg, plot_style,
)
.await
))
},
Message::PrintResult,
)
@ -2220,16 +2191,16 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
return Task::none();
};
let tmp = std::env::temp_dir().join("open_cad_studio_preview.pdf");
let exp = crate::io::pdf_export::export_pdf(
return background_task(
move || {
crate::io::pdf_export::export_pdf(
&w_wires, &w_hatches, &w_wipeouts, sw, sh, wox, woy, 0, wscale, wclip, &tmp,
plot_style.as_ref(),
).and_then(|_| crate::io::print_to_printer::open_in_viewer(&tmp))
.map(|_| "Opened plot preview.".to_string()).map_err(|e| format!("Preview failed: {e}"))
},
|result| Message::BackgroundIoFinished(result, true),
);
match exp.and_then(|_| crate::io::print_to_printer::open_in_viewer(&tmp)) {
Ok(()) => self.command_line.push_info("Opened plot preview."),
Err(e) => self.command_line.push_error(&format!("Preview failed: {e}")),
}
self.active_modal = Some(crate::app::ModalKind::Plot);
return Task::none();
}
if d.to_file {
// Tested clipped export (opens a save dialog).
@ -2244,25 +2215,27 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
return Task::none();
};
let tmp = std::env::temp_dir().join("open_cad_studio_print.pdf");
let exp = crate::io::pdf_export::export_pdf(
let opts = self.plot_print_options(&d);
return background_task(
move || {
crate::io::pdf_export::export_pdf(
&w_wires, &w_hatches, &w_wipeouts, sw, sh, wox, woy, 0, wscale, wclip, &tmp,
plot_style.as_ref(),
).and_then(|_| crate::io::print_to_printer::print_existing_pdf(&tmp, &opts))
.map(|printer| format!("Sent to printer: {printer}"))
.map_err(|e| format!("Print failed: {e}"))
},
|result| Message::BackgroundIoFinished(result, false),
);
let opts = self.plot_print_options(&d);
match exp.and_then(|_| crate::io::print_to_printer::print_existing_pdf(&tmp, &opts)) {
Ok(printer) => self
.command_line
.push_info(&format!("Sent to printer: {printer}")),
Err(e) => self.command_line.push_error(&format!("Print failed: {e}")),
}
return Task::none();
}
let (wires, hatches, wipeouts, eff_w, eff_h, ox, oy, rot) = self.layout_plot_params();
if preview {
let tmp = std::env::temp_dir().join("open_cad_studio_preview.pdf");
let res = crate::io::pdf_export::export_pdf(
return background_task(
move || {
crate::io::pdf_export::export_pdf(
&wires,
&hatches,
&wipeouts,
@ -2275,14 +2248,11 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
None,
&tmp,
plot_style.as_ref(),
).and_then(|_| crate::io::print_to_printer::open_in_viewer(&tmp))
.map(|_| "Opened plot preview.".to_string()).map_err(|e| format!("Preview failed: {e}"))
},
|result| Message::BackgroundIoFinished(result, true),
);
match res.and_then(|_| crate::io::print_to_printer::open_in_viewer(&tmp)) {
Ok(()) => self.command_line.push_info("Opened plot preview."),
Err(e) => self.command_line.push_error(&format!("Preview failed: {e}")),
}
// Preview leaves the dialog open for further tweaks.
self.active_modal = Some(crate::app::ModalKind::Plot);
return Task::none();
}
if d.to_file {
@ -2292,12 +2262,12 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
let opts = self.plot_print_options(&d);
self.command_line.push_info("Sending to system printer…");
Task::perform(
async move {
background_task(
move || {
iced::futures::executor::block_on(
crate::io::print_to_printer::print_wires_with(
wires, hatches, wipeouts, eff_w, eff_h, ox, oy, rot, plot_style, opts,
)
.await
))
},
Message::PrintResult,
)

View file

@ -449,20 +449,26 @@ impl OpenCADStudio {
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "unknown".into());
let phase = std::sync::Arc::new(std::sync::atomic::AtomicU8::new(
let progress = std::sync::Arc::new(crate::io::OpenProgressState::new(
super::OPEN_PHASE_READING,
));
self.opening = Some(super::OpenProgress {
name: name.clone(),
size_bytes,
phase: phase.clone(),
state: progress.clone(),
started: Instant::now(),
});
let size_label = format_size(size_bytes);
self.command_line
.push_info(&format!("Opening \"{name}\" ({size_label})…"));
let model_bg = self.default_bg_color.unwrap_or([
33.0 / 255.0,
40.0 / 255.0,
48.0 / 255.0,
1.0,
]);
Task::perform(
crate::io::open_path_with_phase(path, phase),
crate::io::open_path_with_phase(path, progress, model_bg),
Message::FileOpened,
)
}
@ -579,6 +585,19 @@ impl OpenCADStudio {
Message::WblockSaveResult(_, None) => Task::none(),
Message::WblockWriteFinished(block_name, path, result) => {
match result {
Ok(()) => self.command_line.push_output(&format!(
"WBLOCK Saved \"{block_name}\" → \"{}\"",
path.display()
)),
Err(error) => self
.command_line
.push_error(&format!("WBLOCK save failed: {error}")),
}
Task::none()
}
Message::DataExtractionSave(csv) => {
let csv_clone = csv.clone();
Task::perform(
@ -643,6 +662,16 @@ impl OpenCADStudio {
Message::StlExportPath(None) => Task::none(),
Message::StlExportFinished(path, result) => {
match result {
Ok(()) => self
.command_line
.push_output(&format!("STLOUT: exported to \"{}\"", path.display())),
Err(error) => self.command_line.push_error(&format!("STLOUT: {error}")),
}
Task::none()
}
// ── STEP AP203 export ─────────────────────────────────────────
Message::StepExport => {
let i = self.active_tab;
@ -670,6 +699,16 @@ impl OpenCADStudio {
Message::StepExportPath(None) => Task::none(),
Message::StepExportFinished(path, result) => {
match result {
Ok(()) => self
.command_line
.push_output(&format!("STEPOUT: exported to \"{}\"", path.display())),
Err(error) => self.command_line.push_error(&format!("STEPOUT: {error}")),
}
Task::none()
}
// ── OBJ import ────────────────────────────────────────────────
Message::ObjImport => Task::perform(
async {
@ -688,6 +727,38 @@ impl OpenCADStudio {
Message::ObjImportPath(None) => Task::none(),
Message::ObjImportFinished(tab_id, path, result) => {
match result {
Err(error) => self.command_line.push_error(&format!("IMPORTOBJ: {error}")),
Ok(mut mesh) => {
let Some(i) = self.tabs.iter().position(|tab| tab.id == tab_id) else {
self.command_line
.push_info("IMPORTOBJ: target drawing was closed.");
return Task::none();
};
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");
let entity = crate::modules::insert::solid3d_cmds::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 \"{file_stem}\" as mesh."
));
}
}
}
Task::none()
}
Message::SaveFile => self.on_save_file(),
Message::SaveAs => {
@ -1132,7 +1203,7 @@ impl OpenCADStudio {
let targets = self.layer_row_action_targets(i, idx);
if let Some(on) = on {
if !targets.is_empty() {
self.push_undo_snapshot(i, "LAYER OFF/ON");
let undo = self.begin_layer_undo(i, "LAYER OFF/ON", &targets);
for name in &targets {
if let Some(dl) = self.tabs[i].scene.document.layers.get_mut(name) {
dl.flags.off = !on;
@ -1143,8 +1214,9 @@ impl OpenCADStudio {
pl.visible = on;
}
}
self.tabs[i].scene.bump_geometry();
self.tabs[i].scene.invalidate_layer_dependencies(&targets);
self.tabs[i].dirty = true;
self.commit_layer_undo(i, undo);
self.command_line.push_output(&format!(
"{} layer(s) turned {}",
targets.len(),
@ -1171,7 +1243,7 @@ impl OpenCADStudio {
let targets = self.layer_row_action_targets(i, idx);
if let Some(locked) = locked {
if !targets.is_empty() {
self.push_undo_snapshot(i, "LAYER LOCK/UNLOCK");
let undo = self.begin_layer_undo(i, "LAYER LOCK/UNLOCK", &targets);
for name in &targets {
if let Some(dl) = self.tabs[i].scene.document.layers.get_mut(name) {
dl.flags.locked = locked;
@ -1182,8 +1254,9 @@ impl OpenCADStudio {
pl.locked = locked;
}
}
self.tabs[i].scene.bump_geometry();
// Lock state affects editability, not rendered geometry.
self.tabs[i].dirty = true;
self.commit_layer_undo(i, undo);
self.command_line.push_output(&format!(
"{} layer(s) {}",
targets.len(),
@ -1201,7 +1274,7 @@ impl OpenCADStudio {
let targets = self.layer_row_action_targets(i, idx);
if let Some(frozen) = frozen {
if !targets.is_empty() {
self.push_undo_snapshot(i, "LAYER FREEZE");
let undo = self.begin_layer_undo(i, "LAYER FREEZE", &targets);
for name in &targets {
if let Some(dl) = self.tabs[i].scene.document.layers.get_mut(name) {
if frozen {
@ -1216,8 +1289,9 @@ impl OpenCADStudio {
pl.frozen = frozen;
}
}
self.tabs[i].scene.bump_geometry();
self.tabs[i].scene.invalidate_layer_dependencies(&targets);
self.tabs[i].dirty = true;
self.commit_layer_undo(i, undo);
self.command_line.push_output(&format!(
"{} layer(s) {}",
targets.len(),
@ -1321,6 +1395,7 @@ impl OpenCADStudio {
// Apply to every selected layer (multi-select), not just one.
let names = self.selected_layer_names(i);
if !names.is_empty() {
let undo = self.begin_layer_undo(i, "LAYER COLOR", &names);
use crate::ui::window::layers::iced_color_from_acad;
let new_color = iced_color_from_acad(&AcadColor::Index(aci));
for name in &names {
@ -1334,10 +1409,11 @@ impl OpenCADStudio {
}
}
self.tabs[i].dirty = true;
self.commit_layer_undo(i, undo);
// ByLayer color is baked into the cached wires at
// tessellation time, so bump the geometry epoch to
// invalidate the wire cache and repaint with the new color.
self.tabs[i].scene.bump_geometry();
self.tabs[i].scene.invalidate_layer_dependencies(&names);
self.tabs[i].layers.color_picker_row = None;
self.tabs[i].layers.color_full_palette = false;
self.sync_ribbon_layers();
@ -1349,6 +1425,7 @@ impl OpenCADStudio {
let i = self.active_tab;
let names = self.selected_layer_names(i);
if !names.is_empty() {
let undo = self.begin_layer_undo(i, "LAYER LINETYPE", &names);
for name in &names {
if let Some(dl) = self.tabs[i].scene.document.layers.get_mut(name) {
dl.line_type = lt.clone();
@ -1360,8 +1437,9 @@ impl OpenCADStudio {
}
}
self.tabs[i].dirty = true;
self.commit_layer_undo(i, undo);
// Linetype is baked into the cached wires; repaint.
self.tabs[i].scene.bump_geometry();
self.tabs[i].scene.invalidate_layer_dependencies(&names);
}
Task::none()
}
@ -1370,6 +1448,7 @@ impl OpenCADStudio {
let i = self.active_tab;
let names = self.selected_layer_names(i);
if !names.is_empty() {
let undo = self.begin_layer_undo(i, "LAYER LINEWEIGHT", &names);
for name in &names {
if let Some(dl) = self.tabs[i].scene.document.layers.get_mut(name) {
dl.line_weight = lw;
@ -1381,8 +1460,9 @@ impl OpenCADStudio {
}
}
self.tabs[i].dirty = true;
self.commit_layer_undo(i, undo);
// Lineweight is baked into the cached wires; repaint.
self.tabs[i].scene.bump_geometry();
self.tabs[i].scene.invalidate_layer_dependencies(&names);
}
Task::none()
}
@ -3984,6 +4064,17 @@ impl OpenCADStudio {
}
Message::PlotWindowExportPath(Some(path)) => self.on_plot_window_export_path_some(path),
Message::BackgroundIoFinished(result, reopen_plot) => {
match result {
Ok(message) => self.command_line.push_info(&message),
Err(error) => self.command_line.push_error(&error),
}
if reopen_plot {
self.active_modal = Some(crate::app::ModalKind::Plot);
}
Task::none()
}
// ── Print to system printer ───────────────────────────────────────
Message::PrintToPrinter => self.on_print_to_printer(),
Message::PrintResult(Ok(printer)) => {