perf: keep heavy work off UI thread

This commit is contained in:
Hakan Seven committed 2026-07-25 18:49:18 +03:00
1 parent 12bf126646
commit deec48ad99
75 files changed
+11702 -518

No files matched your search

+4 -2
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));
+55 -12
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}°."
));
+50 -29
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));
+59 -2
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>,
+417 -14
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}"));
}
+16 -4
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 {
+81 -13
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,
+179 -209
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,
)
+103 -12
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)) => {
+149 -20
View File
@@ -18,20 +18,58 @@ pub mod patterns;
pub mod update_check;
pub mod paper_sizes;
pub mod thumbnail;
#[cfg(target_arch = "wasm32")]
mod web_worker;
use crate::scene::DerivedCaches;
use acadrust::entities::EntityType;
use acadrust::io::dwg::DwgReader;
use acadrust::{CadDocument, DwgWriter, DxfReader, DxfWriter};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::atomic::{AtomicU16, AtomicU32, AtomicU8, Ordering};
use std::sync::Arc;
// Phase tags written into the shared atomic so the UI overlay can display a
// human-readable label. Kept in sync with the constants in `crate::app`.
const PHASE_PARSING: u8 = 1;
const PHASE_CACHING: u8 = 2;
const PHASE_FINALIZING: u8 = 3;
/// Thread-safe state shared by the native loader and the open overlay.
///
/// `basis_points` is monotonic in 0..=10000. `completed/total` describe the
/// current sub-stage and let diagnostics distinguish real progress from a
/// cosmetic timer.
#[derive(Debug)]
pub struct OpenProgressState {
pub phase: AtomicU8,
pub basis_points: AtomicU16,
pub completed: AtomicU32,
pub total: AtomicU32,
}
impl OpenProgressState {
pub fn new(phase: u8) -> Self {
Self {
phase: AtomicU8::new(phase),
basis_points: AtomicU16::new(0),
completed: AtomicU32::new(0),
total: AtomicU32::new(1),
}
}
pub fn set(&self, phase: u8, basis_points: u16, completed: usize, total: usize) {
self.completed
.store(completed.min(u32::MAX as usize) as u32, Ordering::Relaxed);
self.total.store(
total.max(1).min(u32::MAX as usize) as u32,
Ordering::Relaxed,
);
self.basis_points
.fetch_max(basis_points.min(10000), Ordering::Relaxed);
self.phase.store(phase, Ordering::Release);
}
pub fn set_fraction(&self, phase: u8, base: u16, span: u16, completed: usize, total: usize) {
let denominator = total.max(1) as u64;
let value = base as u64 + (completed.min(total.max(1)) as u64 * span as u64 / denominator);
self.set(phase, value.min(10000) as u16, completed, total);
}
}
// ── Open ──────────────────────────────────────────────────────────────────
@@ -61,37 +99,103 @@ pub async fn pick_open_path() -> Option<(PathBuf, u64)> {
/// thread runs.
pub async fn open_path_with_phase(
path: PathBuf,
phase: Arc<AtomicU8>,
progress: Arc<OpenProgressState>,
model_bg: [f32; 4],
) -> Result<(String, PathBuf, CadDocument, DerivedCaches), String> {
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "unknown".into());
let path2 = path.clone();
let phase2 = phase.clone();
let (doc, caches) = std::thread::spawn(move || -> Result<_, String> {
let progress2 = progress.clone();
let (sender, receiver) = iced::futures::channel::oneshot::channel();
std::thread::Builder::new()
.name("ocs-file-open".to_string())
.spawn(move || {
let result = (|| -> Result<_, String> {
use iced::time::Instant;
phase2.store(PHASE_PARSING, Ordering::Relaxed);
progress2.set(crate::app::OPEN_PHASE_PARSING, 200, 0, 1000);
let t_parse = Instant::now();
let mut doc = load_file(&path2)?;
let parser_progress = {
let progress = Arc::clone(&progress2);
let callback: Arc<dyn Fn(u16) + Send + Sync> = Arc::new(move |value| {
progress.set_fraction(
crate::app::OPEN_PHASE_PARSING,
200,
5600,
value as usize,
1000,
);
});
callback
};
let mut doc = load_file_with_progress(&path2, Some(parser_progress))?;
let parse_ms = t_parse.elapsed().as_millis() as u32;
progress2.set(crate::app::OPEN_PHASE_PARSING, 5800, 1000, 1000);
let t_purge = Instant::now();
let dropped = purge_corrupt_entities(&mut doc);
let purge_ms = t_purge.elapsed().as_millis() as u32;
phase2.store(PHASE_CACHING, Ordering::Relaxed);
progress2.set(crate::app::OPEN_PHASE_XREF, 6000, 0, 1);
let t_xref = Instant::now();
let (xref_infos, xref_dropped) = if let Some(base_dir) = path2.parent() {
let xref_progress = {
let progress = Arc::clone(&progress2);
let callback: Arc<dyn Fn(usize, usize) + Send + Sync> =
Arc::new(move |completed, total| {
progress.set_fraction(
crate::app::OPEN_PHASE_XREF,
6000,
1400,
completed,
total,
);
});
callback
};
crate::io::xref::resolve_xrefs_with_progress(
&mut doc,
base_dir,
Some(xref_progress),
)
} else {
(Vec::new(), 0)
};
let xref_ms = t_xref.elapsed().as_millis() as u32;
progress2.set(crate::app::OPEN_PHASE_CACHING, 7400, 0, 10000);
let t_caches = Instant::now();
let mut caches = crate::scene::build_derived_caches(&doc);
let cache_progress = |value: u16| {
progress2.set_fraction(
crate::app::OPEN_PHASE_CACHING,
7400,
2200,
value as usize,
10000,
);
};
let mut caches = crate::scene::build_derived_caches_with_progress(&doc, &cache_progress);
caches.timings = crate::scene::OpenTimings {
parse_ms,
purge_ms,
caches_ms: t_caches.elapsed().as_millis() as u32,
xref_ms,
};
caches.corrupt_dropped = dropped;
phase2.store(PHASE_FINALIZING, Ordering::Relaxed);
caches.xref_dropped = xref_dropped;
caches.xrefs = xref_infos;
progress2.set(crate::app::OPEN_PHASE_FINALIZING, 9600, 0, 1);
let (prepared_doc, prepared_geometry) =
crate::scene::prepare_open_geometry(doc, &caches, model_bg);
doc = prepared_doc;
caches.prepared_geometry = Some(prepared_geometry);
progress2.set(crate::app::OPEN_PHASE_FINALIZING, 9950, 1, 1);
Ok((doc, caches))
})();
let _ = sender.send(result);
})
.join()
.map_err(|_| "parser thread panicked".to_string())??;
.map_err(|error| format!("failed to start parser thread: {error}"))?;
let (doc, caches) = receiver
.await
.map_err(|_| "parser thread stopped without a result".to_string())??;
Ok((name, path, doc, caches))
}
@@ -102,6 +206,7 @@ pub async fn open_path_with_phase(
/// stands in for the document path.
#[cfg(target_arch = "wasm32")]
pub async fn pick_and_load_web(
progress: Arc<OpenProgressState>,
) -> Result<(String, PathBuf, CadDocument, DerivedCaches), String> {
let handle = crate::sys::file_dialog()
.set_title("Open CAD file")
@@ -111,11 +216,23 @@ pub async fn pick_and_load_web(
.await
.ok_or_else(|| "Cancelled".to_string())?;
let name = handle.file_name();
progress.set(crate::app::OPEN_PHASE_READING, 500, 1, 2);
let bytes = handle.read().await;
let mut doc = load_bytes(&name, bytes)?;
progress.set(crate::app::OPEN_PHASE_PARSING, 1000, 0, 1);
let mut doc = match web_worker::parse_document(&name, bytes).await {
Ok(document) => document,
Err(error) => return Err(format!("Web parser worker: {error}")),
};
if name.to_ascii_lowercase().ends_with(".dxf") {
fix_dxf_dimension_rotations(&mut doc);
}
fix_viewport_status_flags(&mut doc);
fix_current_style_names(&mut doc);
progress.set(crate::app::OPEN_PHASE_CACHING, 7000, 0, 1);
let dropped = purge_corrupt_entities(&mut doc);
let mut caches = crate::scene::build_derived_caches(&doc);
caches.corrupt_dropped = dropped;
progress.set(crate::app::OPEN_PHASE_FINALIZING, 9900, 1, 1);
let path = PathBuf::from(&name);
Ok((name, path, doc, caches))
}
@@ -168,6 +285,13 @@ fn sniff_dwg_or_dxf(path: &Path) -> String {
}
pub fn load_file(path: &Path) -> Result<CadDocument, String> {
load_file_with_progress(path, None)
}
pub(crate) fn load_file_with_progress(
path: &Path,
_progress: Option<Arc<dyn Fn(u16) + Send + Sync>>,
) -> Result<CadDocument, String> {
let ext = path
.extension()
.map(|e| e.to_string_lossy().to_lowercase())
@@ -184,10 +308,15 @@ pub fn load_file(path: &Path) -> Result<CadDocument, String> {
match effective.as_str() {
"dwg" => {
#[cfg(not(target_arch = "wasm32"))]
let mut doc = DwgReader::from_mmap(path)
.map_err(|e| e.to_string())?
.read()
let mut doc = {
let mut reader = DwgReader::from_mmap(path)
.map_err(|e| e.to_string())?;
if let Some(progress) = _progress {
reader.set_progress_callback(progress);
}
reader.read()
.map_err(|e| e.to_string())?
};
#[cfg(target_arch = "wasm32")]
let mut doc = DwgReader::from_file(path)
.map_err(|e| e.to_string())?
+77
View File
@@ -0,0 +1,77 @@
use std::cell::RefCell;
use std::rc::Rc;
use acadrust::CadDocument;
use js_sys::{Array, Object, Reflect, Uint8Array};
use wasm_bindgen::closure::Closure;
use wasm_bindgen::{JsCast, JsValue};
use web_sys::{ErrorEvent, MessageEvent, Worker, WorkerOptions, WorkerType};
pub(super) async fn parse_document(name: &str, bytes: Vec<u8>) -> Result<CadDocument, String> {
let options = WorkerOptions::new();
options.set_type(WorkerType::Module);
let worker = Worker::new_with_options("ocs-parse-worker.js", &options).map_err(js_error)?;
let (sender, receiver) = iced::futures::channel::oneshot::channel();
let sender = Rc::new(RefCell::new(Some(sender)));
let message_sender = sender.clone();
let on_message = Closure::<dyn FnMut(MessageEvent)>::new(move |event: MessageEvent| {
let data = event.data();
let ok = Reflect::get(&data, &JsValue::from_str("ok"))
.ok()
.and_then(|value| value.as_bool())
.unwrap_or(false);
let result = if ok {
Reflect::get(&data, &JsValue::from_str("data"))
.map_err(js_error)
.and_then(|value| {
let bytes = Uint8Array::new(&value).to_vec();
bincode::deserialize(&bytes).map_err(|error| error.to_string())
})
} else {
Err(Reflect::get(&data, &JsValue::from_str("error"))
.ok()
.and_then(|value| value.as_string())
.unwrap_or_else(|| "CAD parser worker failed".to_string()))
};
if let Some(sender) = message_sender.borrow_mut().take() {
let _ = sender.send(result);
}
});
worker.set_onmessage(Some(on_message.as_ref().unchecked_ref()));
let error_sender = sender;
let on_error = Closure::<dyn FnMut(ErrorEvent)>::new(move |event: ErrorEvent| {
if let Some(sender) = error_sender.borrow_mut().take() {
let _ = sender.send(Err(event.message()));
}
});
worker.set_onerror(Some(on_error.as_ref().unchecked_ref()));
let payload = Object::new();
Reflect::set(
&payload,
&JsValue::from_str("name"),
&JsValue::from_str(name),
)
.map_err(js_error)?;
let input = Uint8Array::from(bytes.as_slice());
Reflect::set(&payload, &JsValue::from_str("bytes"), &input.buffer()).map_err(js_error)?;
let transfer = Array::new();
transfer.push(&input.buffer());
worker
.post_message_with_transfer(&payload, &transfer)
.map_err(js_error)?;
let result = receiver
.await
.map_err(|_| "CAD parser worker closed without a result".to_string())?;
worker.terminate();
result
}
fn js_error(value: JsValue) -> String {
value
.as_string()
.unwrap_or_else(|| format!("browser worker error: {value:?}"))
}
+59 -3
View File
@@ -41,6 +41,19 @@ pub struct XrefInfo {
/// just like the host doc, so it gets the same corrupt-entity guard. Folding
/// it in here avoids a second full-document `entities()` walk after resolve.
pub fn resolve_xrefs(doc: &mut CadDocument, base_dir: &Path) -> (Vec<XrefInfo>, usize) {
resolve_xrefs_with_progress(doc, base_dir, None)
}
/// Resolve XREFs while reporting completed work units.
///
/// Each reference contributes 1000 parse units and 1000 merge units. Parsing
/// progress comes directly from the DWG reader when available, so one large
/// XREF advances smoothly instead of making the file-open bar appear frozen.
pub fn resolve_xrefs_with_progress(
doc: &mut CadDocument,
base_dir: &Path,
progress: Option<std::sync::Arc<dyn Fn(usize, usize) + Send + Sync>>,
) -> (Vec<XrefInfo>, usize) {
// Auto-resolve every xref — frustum + LOD culling keep GPU cost bounded.
let xref_entries: Vec<(String, String, Handle)> = doc
.block_records
@@ -48,6 +61,11 @@ pub fn resolve_xrefs(doc: &mut CadDocument, base_dir: &Path) -> (Vec<XrefInfo>,
.filter(|br| (br.flags.is_xref || br.flags.is_xref_overlay) && !br.xref_path.is_empty())
.map(|br| (br.name.clone(), br.xref_path.clone(), br.handle))
.collect();
let xref_count = xref_entries.len();
let total_units = xref_count.saturating_mul(2000);
if let Some(progress) = &progress {
progress(0, total_units);
}
// Phase 1 — parse every referenced file in parallel. Each `load_file`
// reads and decodes an independent DWG/DXF and touches nothing in the host
@@ -55,11 +73,40 @@ pub fn resolve_xrefs(doc: &mut CadDocument, base_dir: &Path) -> (Vec<XrefInfo>,
// instead of running back-to-back. (The merge in phase 2 mutates `doc`, so
// it stays serial.) `resolve_path` is pure and `base_dir` is shared &-ref.
use crate::par::prelude::*;
let parse_units: std::sync::Arc<Vec<std::sync::atomic::AtomicU16>> = std::sync::Arc::new(
(0..xref_count)
.map(|_| std::sync::atomic::AtomicU16::new(0))
.collect(),
);
let parsed: Vec<(String, String, Handle, Option<PathBuf>, Option<CadDocument>)> = xref_entries
.into_par_iter()
.map(|(block_name, raw_path, br_handle)| {
.enumerate()
.map(|(xref_index, (block_name, raw_path, br_handle))| {
let resolved = resolve_path(&raw_path, base_dir);
let xref_doc = resolved.as_ref().and_then(|p| super::load_file(p).ok());
let units = std::sync::Arc::clone(&parse_units);
let nested_progress = progress.as_ref().map(|progress| {
let progress = std::sync::Arc::clone(progress);
let callback: std::sync::Arc<dyn Fn(u16) + Send + Sync> =
std::sync::Arc::new(move |value| {
units[xref_index]
.store(value.min(1000), std::sync::atomic::Ordering::Relaxed);
let completed = units
.iter()
.map(|unit| unit.load(std::sync::atomic::Ordering::Relaxed) as usize)
.sum();
progress(completed, total_units);
});
callback
});
let xref_doc = resolved.as_ref().and_then(|p| super::load_file_with_progress(p, nested_progress).ok());
parse_units[xref_index].store(1000, std::sync::atomic::Ordering::Relaxed);
if let Some(progress) = &progress {
let completed = parse_units
.iter()
.map(|unit| unit.load(std::sync::atomic::Ordering::Relaxed) as usize)
.sum();
progress(completed, total_units);
}
(block_name, raw_path, br_handle, resolved, xref_doc)
})
.collect();
@@ -68,7 +115,8 @@ pub fn resolve_xrefs(doc: &mut CadDocument, base_dir: &Path) -> (Vec<XrefInfo>,
// block order (par_iter preserves it), so handle allocation is deterministic.
let mut result = Vec::with_capacity(parsed.len());
let mut dropped = 0usize;
for (block_name, raw_path, br_handle, resolved, xref_doc) in parsed {
for (merge_index, (block_name, raw_path, br_handle, resolved, xref_doc)) in parsed.into_iter().enumerate()
{
let status = if let Some(xref_doc) = xref_doc {
ensure_block_entities(doc, &block_name);
dropped += merge_xref_into_block(doc, &block_name, br_handle, xref_doc);
@@ -85,6 +133,14 @@ pub fn resolve_xrefs(doc: &mut CadDocument, base_dir: &Path) -> (Vec<XrefInfo>,
.unwrap_or(raw_path),
status,
});
if let Some(progress) = &progress {
progress(
xref_count
.saturating_mul(1000)
.saturating_add((merge_index + 1).saturating_mul(1000)),
total_units,
);
}
}
(result, dropped)
+99
View File
@@ -0,0 +1,99 @@
//! Shared broad phase for interactive modify commands.
//!
//! The scene already narrows the cursor pick. Commands still need fast access
//! to the picked analytic entity and, for intersection-heavy operations such
//! as TRIM, to nearby boundary entities. Keeping this compact command-local
//! index avoids cloning/scanning the complete drawing on every mouse move.
use acadrust::{EntityType, Handle};
use rustc_hash::{FxHashMap, FxHashSet};
use crate::scene::convert::tess::entity_world_aabb_f64;
use crate::scene::pick::quadtree::QuadTree;
pub(super) struct ModifyEntityIndex {
by_handle: FxHashMap<Handle, usize>,
tree: Option<QuadTree>,
unbounded: Vec<Handle>,
}
impl ModifyEntityIndex {
pub(super) fn build(entities: &[EntityType]) -> Self {
let mut by_handle = FxHashMap::default();
let mut bounded = Vec::new();
let mut unbounded = Vec::new();
let mut world = [
f64::INFINITY,
f64::INFINITY,
f64::NEG_INFINITY,
f64::NEG_INFINITY,
];
for (index, entity) in entities.iter().enumerate() {
let handle = entity.common().handle;
by_handle.insert(handle, index);
if let Some(aabb) = entity_world_aabb_f64(entity) {
world[0] = world[0].min(aabb[0]);
world[1] = world[1].min(aabb[1]);
world[2] = world[2].max(aabb[2]);
world[3] = world[3].max(aabb[3]);
bounded.push((handle, aabb));
} else {
unbounded.push(handle);
}
}
let tree = if bounded.is_empty() {
None
} else {
let span = (world[2] - world[0]).max(world[3] - world[1]).max(1.0);
let pad = span * 1.0e-9 + 1.0e-6;
let mut tree = QuadTree::new([
world[0] - pad,
world[1] - pad,
world[2] + pad,
world[3] + pad,
]);
for (handle, aabb) in bounded {
tree.insert(handle, aabb);
}
Some(tree)
};
Self {
by_handle,
tree,
unbounded,
}
}
#[inline]
pub(super) fn get<'a>(
&self,
entities: &'a [EntityType],
handle: Handle,
) -> Option<&'a EntityType> {
self.by_handle
.get(&handle)
.and_then(|index| entities.get(*index))
}
pub(super) fn nearby_handles(
&self,
entities: &[EntityType],
handle: Handle,
) -> Option<FxHashSet<Handle>> {
let entity = self.get(entities, handle)?;
let aabb = entity_world_aabb_f64(entity)?;
let span = (aabb[2] - aabb[0]).max(aabb[3] - aabb[1]).max(1.0);
let pad = span * 1.0e-10 + 1.0e-7;
let query = [aabb[0] - pad, aabb[1] - pad, aabb[2] + pad, aabb[3] + pad];
let mut handles: FxHashSet<Handle> = self
.tree
.as_ref()
.map(|tree| tree.query_rect(query).into_iter().collect())
.unwrap_or_default();
handles.extend(self.unbounded.iter().copied());
Some(handles)
}
}
+23 -24
View File
@@ -21,6 +21,8 @@ use crate::modules::draw::defaults;
use crate::modules::IconKind;
use crate::scene::model::wire_model::WireModel;
use super::entity_index::ModifyEntityIndex;
// ── Dropdown constants ─────────────────────────────────────────────────────
pub const DROPDOWN_ID: &str = "fillet_chamfer";
@@ -1204,6 +1206,7 @@ pub struct FilletCommand {
radius: f64,
step: FilletStep,
all_entities: Vec<EntityType>,
entity_index: ModifyEntityIndex,
/// First-object pick to restore after a radius entry made mid-selection
/// (i.e. "R" pressed after the first object was already picked), so the
/// command resumes at the second pick instead of restarting selection.
@@ -1212,10 +1215,12 @@ pub struct FilletCommand {
impl FilletCommand {
pub fn new(radius: f64, all_entities: Vec<EntityType>) -> Self {
let entity_index = ModifyEntityIndex::build(&all_entities);
Self {
radius: radius as f64,
step: FilletStep::First,
all_entities,
entity_index,
resume_second: None,
}
}
@@ -1348,9 +1353,8 @@ impl CadCommand for FilletCommand {
FilletStep::WaitingForRadius => return CmdResult::NeedPoint,
FilletStep::First => {
let e1 = self
.all_entities
.iter()
.find(|e| e.common().handle == handle)
.entity_index
.get(&self.all_entities, handle)
.and_then(|entity| match entity {
EntityType::LwPolyline(p) => {
Some(FilletEntity::from_lwpoly(p, handle, click))
@@ -1380,9 +1384,8 @@ impl CadCommand for FilletCommand {
}
let e2 = self
.all_entities
.iter()
.find(|e| e.common().handle == handle)
.entity_index
.get(&self.all_entities, handle)
.and_then(|entity| match entity {
EntityType::LwPolyline(p) => {
Some(FilletEntity::from_lwpoly(p, handle, click))
@@ -1426,9 +1429,8 @@ impl CadCommand for FilletCommand {
FilletStep::WaitingForRadius => vec![],
FilletStep::First => {
let pts = self
.all_entities
.iter()
.find(|e| e.common().handle == handle)
.entity_index
.get(&self.all_entities, handle)
.and_then(|e| match e {
EntityType::LwPolyline(p) => Some(lwpoly_seg_hover_pts(p, click)),
_ => FilletEntity::from_entity(e).map(|fe| entity_pts(&fe.to_entity_type())),
@@ -1449,9 +1451,8 @@ impl CadCommand for FilletCommand {
let e1 = e1.clone();
let click1 = *click1;
let e2 = self
.all_entities
.iter()
.find(|e| e.common().handle == handle)
.entity_index
.get(&self.all_entities, handle)
.and_then(|entity| match entity {
EntityType::LwPolyline(p) => {
Some(FilletEntity::from_lwpoly(p, handle, click))
@@ -1583,6 +1584,7 @@ pub struct ChamferCommand {
dist2: f64,
step: ChamferStep,
all_entities: Vec<EntityType>,
entity_index: ModifyEntityIndex,
/// First-object pick (line or polyline segment) to restore after a
/// distance entry made mid-selection, so the command resumes at the
/// second pick instead of restarting selection.
@@ -1591,11 +1593,13 @@ pub struct ChamferCommand {
impl ChamferCommand {
pub fn new(dist: f64, all_entities: Vec<EntityType>) -> Self {
let entity_index = ModifyEntityIndex::build(&all_entities);
Self {
dist1: dist as f64,
dist2: defaults::get_chamfer_dist2(),
step: ChamferStep::First,
all_entities,
entity_index,
resume_pick: None,
}
}
@@ -1777,9 +1781,7 @@ impl CadCommand for ChamferCommand {
}
ChamferStep::First => {
match self
.all_entities
.iter()
.find(|e| e.common().handle == handle)
.entity_index.get(&self.all_entities, handle)
{
Some(EntityType::Line(l)) => {
self.step = ChamferStep::Second {
@@ -1821,9 +1823,8 @@ impl CadCommand for ChamferCommand {
}
let l2 = self
.all_entities
.iter()
.find(|e| e.common().handle == handle)
.entity_index
.get(&self.all_entities, handle)
.and_then(|e| {
if let EntityType::Line(l) = e {
Some(l.clone())
@@ -1857,9 +1858,8 @@ impl CadCommand for ChamferCommand {
ChamferStep::WaitingForDist1 | ChamferStep::WaitingForDist2 => return vec![],
ChamferStep::First => {
let pts = self
.all_entities
.iter()
.find(|e| e.common().handle == handle)
.entity_index
.get(&self.all_entities, handle)
.and_then(|e| match e {
EntityType::Line(l) => Some(line_pts(l)),
EntityType::LwPolyline(p) => Some(lwpoly_seg_hover_pts(p, click)),
@@ -1880,9 +1880,8 @@ impl CadCommand for ChamferCommand {
let l1 = l1.clone();
let click1 = *click1;
let l2 = self
.all_entities
.iter()
.find(|e| e.common().handle == handle)
.entity_index
.get(&self.all_entities, handle)
.and_then(|e| {
if let EntityType::Line(l) = e {
Some(l.clone())
+1
View File
@@ -4,6 +4,7 @@ pub mod attedit;
pub mod break_cmd;
pub mod copy;
pub mod delete;
mod entity_index;
pub mod explode;
pub mod fillet;
pub mod join;
+9 -6
View File
@@ -25,6 +25,8 @@ use crate::modules::draw::defaults;
use crate::modules::{IconKind, ModuleEvent, ToolDef};
use crate::scene::model::wire_model::WireModel;
use super::entity_index::ModifyEntityIndex;
// ── Ribbon definition ──────────────────────────────────────────────────────
pub fn tool() -> ToolDef {
@@ -691,6 +693,7 @@ enum Step {
pub struct OffsetCommand {
step: Step,
all_entities: Vec<EntityType>,
entity_index: ModifyEntityIndex,
/// Pre-selected offsettable objects (pick-first, #422); consumed when the
/// distance step resolves.
preselected: Vec<EntityType>,
@@ -712,9 +715,11 @@ pub fn is_offsettable(e: &EntityType) -> bool {
impl OffsetCommand {
pub fn new(all_entities: Vec<EntityType>) -> Self {
let entity_index = ModifyEntityIndex::build(&all_entities);
Self {
step: Step::Distance,
all_entities,
entity_index,
preselected: Vec::new(),
}
}
@@ -722,9 +727,11 @@ impl OffsetCommand {
/// Pick-first flow (#422): the distance step still comes first, then the
/// pre-selected objects go straight to the side step.
pub fn with_selection(all_entities: Vec<EntityType>, targets: Vec<EntityType>) -> Self {
let entity_index = ModifyEntityIndex::build(&all_entities);
Self {
step: Step::Distance,
all_entities,
entity_index,
preselected: targets,
}
}
@@ -801,9 +808,7 @@ impl CadCommand for OffsetCommand {
}
let entity = self
.all_entities
.iter()
.find(|e| e.common().handle == handle)
.entity_index.get(&self.all_entities, handle)
.cloned();
// Accept every type compute_offset can offset — including XLine (#296),
@@ -881,9 +886,7 @@ impl CadCommand for OffsetCommand {
return vec![];
}
if let Some(entity) = self
.all_entities
.iter()
.find(|e| e.common().handle == handle)
.entity_index.get(&self.all_entities, handle)
{
let pts = entity_wire_pts(entity);
if !pts.is_empty() {
+58 -22
View File
@@ -27,6 +27,8 @@ use crate::modules::draw::modify::spline_ops::{
use crate::modules::IconKind;
use crate::scene::model::wire_model::WireModel;
use super::entity_index::ModifyEntityIndex;
// ── Dropdown constants ─────────────────────────────────────────────────────
pub const DROPDOWN_ID: &str = "trim_extend";
@@ -269,6 +271,20 @@ enum Geo {
},
}
impl Geo {
fn handle(&self) -> Handle {
match self {
Self::Line { handle, .. }
| Self::Arc { handle, .. }
| Self::Circle { handle, .. }
| Self::Ray { handle, .. }
| Self::InfLine { handle, .. }
| Self::Ellipse { handle, .. }
| Self::Spline { handle, .. } => *handle,
}
}
}
fn build_geos(entities: &[EntityType]) -> Vec<Geo> {
let mut out = Vec::new();
for e in entities {
@@ -2797,6 +2813,7 @@ fn crossing_preview_wire(p1: [f64; 2], cursor: [f64; 2], name: &str) -> WireMode
pub struct TrimCommand {
all_entities: Vec<EntityType>,
entity_index: ModifyEntityIndex,
geos: Vec<Geo>,
mode: TrimMode,
/// Cutting-edge selection; empty = every object cuts (quick mode).
@@ -2809,9 +2826,11 @@ pub struct TrimCommand {
impl TrimCommand {
pub fn new(all_entities: Vec<EntityType>) -> Self {
let entity_index = ModifyEntityIndex::build(&all_entities);
let geos = build_geos(&all_entities);
Self {
all_entities,
entity_index,
geos,
mode: TrimMode::Pick,
edge_set: Vec::new(),
@@ -2823,6 +2842,7 @@ impl TrimCommand {
/// Boundary geometry from the edge selection (or everything when none),
/// with the Edge option's implied extrapolation applied on top.
fn rebuild_geos(&mut self) {
self.entity_index = ModifyEntityIndex::build(&self.all_entities);
self.geos = if self.edge_set.is_empty() {
build_geos(&self.all_entities)
} else {
@@ -2839,6 +2859,23 @@ impl TrimCommand {
}
}
/// Exact analytic boundaries whose boxes overlap the picked entity. The
/// host already narrowed the cursor pick; this second broad phase keeps
/// TRIM preview/intersection work local on dense drawings.
fn nearby_geos(&self, handle: Handle) -> Vec<Geo> {
if self.implied_edges {
return self.geos.clone();
}
let Some(handles) = self.entity_index.nearby_handles(&self.all_entities, handle) else {
return self.geos.clone();
};
self.geos
.iter()
.filter(|geo| handles.contains(&geo.handle()))
.cloned()
.collect()
}
fn fence_run(
&mut self,
fence: &[[f64; 2]],
@@ -2974,7 +3011,8 @@ impl CadCommand for TrimCommand {
pick_extend_at(&self.all_entities, &self.geos, handle, px, py)
.map(|e| vec![e])
} else {
pick_trim_at(&self.all_entities, &self.geos, handle, px, py)
let geos = self.nearby_geos(handle);
pick_trim_at(&self.all_entities, &geos, handle, px, py)
};
if let Some(new_entities) = new_entities {
// Snapshot is updated in on_entity_replaced once we know
@@ -3108,10 +3146,10 @@ impl CadCommand for TrimCommand {
return vec![];
}
let nearby_geos = self.nearby_geos(handle);
let geos = nearby_geos.as_slice();
let entity = self
.all_entities
.iter()
.find(|e| e.common().handle == handle);
.entity_index.get(&self.all_entities, handle);
let mut hover_wires = match entity {
Some(EntityType::Line(l)) => {
@@ -3119,7 +3157,7 @@ impl CadCommand for TrimCommand {
let ay = l.start.y;
let bx = l.end.x;
let by = l.end.y;
let ts = line_seg_ts(ax, ay, bx, by, handle, &self.geos);
let ts = line_seg_ts(ax, ay, bx, by, handle, geos);
if ts.is_empty() {
return vec![];
}
@@ -3152,7 +3190,7 @@ impl CadCommand for TrimCommand {
let cy = a.center.y;
let a0 = a.start_angle;
let a1 = a.end_angle;
let ts = arc_seg_ts(cx, cy, a.radius, a0, a1, handle, &self.geos);
let ts = arc_seg_ts(cx, cy, a.radius, a0, a1, handle, geos);
if ts.is_empty() {
return vec![];
}
@@ -3176,7 +3214,7 @@ impl CadCommand for TrimCommand {
Some(EntityType::Circle(c)) => {
let cx = c.center.x;
let cy = c.center.y;
let ts = arc_seg_ts(cx, cy, c.radius, 0.0, TAU, handle, &self.geos);
let ts = arc_seg_ts(cx, cy, c.radius, 0.0, TAU, handle, geos);
if ts.len() < 2 {
return vec![];
}
@@ -3205,7 +3243,7 @@ impl CadCommand for TrimCommand {
let by = r.base_point.y;
let ex = bx + r.direction.x * TRIM_EXTENT;
let ey = by + r.direction.y * TRIM_EXTENT;
let ts = line_seg_ts(bx, by, ex, ey, handle, &self.geos);
let ts = line_seg_ts(bx, by, ex, ey, handle, geos);
if ts.is_empty() {
return vec![];
}
@@ -3245,7 +3283,7 @@ impl CadCommand for TrimCommand {
let ey_start = by - x.direction.y * TRIM_EXTENT;
let ex_end = bx + x.direction.x * TRIM_EXTENT;
let ey_end = by + x.direction.y * TRIM_EXTENT;
let ts = line_seg_ts(ex_start, ey_start, ex_end, ey_end, handle, &self.geos);
let ts = line_seg_ts(ex_start, ey_start, ex_end, ey_end, handle, geos);
if ts.is_empty() {
return vec![];
}
@@ -3294,7 +3332,7 @@ impl CadCommand for TrimCommand {
t1 += TAU;
}
let ts = ellipse_seg_ts(
e.center.x, e.center.y, a, b, nx, ny, t0, t1, handle, &self.geos,
e.center.x, e.center.y, a, b, nx, ny, t0, t1, handle, geos,
);
if ts.is_empty() {
return vec![];
@@ -3321,7 +3359,7 @@ impl CadCommand for TrimCommand {
out
}
Some(EntityType::Spline(s)) => {
let ts = spline_seg_ts(s, handle, &self.geos);
let ts = spline_seg_ts(s, handle, geos);
if ts.is_empty() {
return vec![];
}
@@ -3348,7 +3386,7 @@ impl CadCommand for TrimCommand {
out
}
Some(EntityType::LwPolyline(p)) => {
let Some(survivors) = trim_lwpolyline(p, pt.x as f64, pt.y as f64, &self.geos)
let Some(survivors) = trim_lwpolyline(p, pt.x as f64, pt.y as f64, geos)
else {
return vec![];
};
@@ -3371,7 +3409,7 @@ impl CadCommand for TrimCommand {
if self.implied_edges && !hover_wires.is_empty() {
if let (Some(orig), Some(pieces)) = (
entity,
pick_trim_at(&self.all_entities, &self.geos, handle, pt.x, pt.y),
pick_trim_at(&self.all_entities, geos, handle, pt.x, pt.y),
) {
let cuts = piece_cut_points(orig, &pieces);
hover_wires.extend(implied_cut_guides(&self.all_entities, handle, &cuts));
@@ -3477,6 +3515,7 @@ impl CadCommand for TrimCommand {
pub struct ExtendCommand {
all_entities: Vec<EntityType>,
entity_index: ModifyEntityIndex,
geos: Vec<Geo>,
mode: TrimMode,
/// Boundary-edge selection; empty = every object is a boundary.
@@ -3489,9 +3528,11 @@ pub struct ExtendCommand {
impl ExtendCommand {
pub fn new(all_entities: Vec<EntityType>) -> Self {
let entity_index = ModifyEntityIndex::build(&all_entities);
let geos = build_geos(&all_entities);
Self {
all_entities,
entity_index,
geos,
mode: TrimMode::Pick,
edge_set: Vec::new(),
@@ -3501,6 +3542,7 @@ impl ExtendCommand {
}
fn rebuild_geos(&mut self) {
self.entity_index = ModifyEntityIndex::build(&self.all_entities);
self.geos = if self.edge_set.is_empty() {
build_geos(&self.all_entities)
} else {
@@ -3681,18 +3723,14 @@ impl CadCommand for ExtendCommand {
let mut out: Vec<WireModel> = self
.edge_set
.iter()
.filter_map(|h| {
self.all_entities.iter().find(|e| e.common().handle == *h)
})
.filter_map(|h| self.entity_index.get(&self.all_entities, *h))
.map(|e| {
WireModel::solid("edge_sel".into(), entity_pts(e), OPT_YELLOW, false)
})
.collect();
if !handle.is_null() && !self.edge_set.contains(&handle) {
if let Some(e) = self
.all_entities
.iter()
.find(|e| e.common().handle == handle)
.entity_index.get(&self.all_entities, handle)
{
let mut c = OPT_YELLOW;
c[3] = 0.45;
@@ -3732,9 +3770,7 @@ impl CadCommand for ExtendCommand {
}
let entity = self
.all_entities
.iter()
.find(|e| e.common().handle == handle);
.entity_index.get(&self.all_entities, handle);
match entity {
Some(EntityType::Line(l)) => {
let ax = l.start.x;
+220 -3
View File
@@ -15,7 +15,7 @@
// a visited set so a self-referential block produces a marker rather than
// recursing forever.
use rustc_hash::FxHashMap as HashMap;
use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
use std::sync::Arc;
use acadrust::types::{Color as AcadColor, LineWeight, Transform, Vector3};
@@ -153,6 +153,29 @@ pub struct BlockDefn {
#[derive(Default, Debug)]
pub struct BlockCache {
defns: HashMap<String, Arc<BlockDefn>>,
prototype_blocks: HashSet<String>,
/// Fully expanded prototype for repeated, non-array inserts. The key omits
/// translation but includes the linear transform and every inherited style
/// input. A matching insert therefore reuses all nested expansion/style
/// work and only applies its translation to the immutable prototype.
expansion_prototypes: std::sync::Mutex<
HashMap<ExpansionPrototypeKey, Arc<std::sync::Mutex<Option<Arc<CachedExpansion>>>>>,
>,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
struct ExpansionPrototypeKey {
block_name: String,
linear: [u64; 9],
insert_style: Vec<u32>,
selected: bool,
is_xref: bool,
}
#[derive(Debug)]
struct CachedExpansion {
translation: [f64; 3],
wires: Arc<Vec<WireModel>>,
}
impl BlockCache {
@@ -181,6 +204,18 @@ impl BlockCache {
use crate::par::prelude::*;
let mut cache = Self::new();
let referenced = collect_referenced_blocks(doc);
let mut reference_counts: HashMap<String, usize> = HashMap::default();
for entity in doc.entities() {
if let EntityType::Insert(insert) = entity {
*reference_counts
.entry(insert.block_name.clone())
.or_default() += insert.instance_count();
}
}
cache.prototype_blocks = reference_counts
.into_iter()
.filter_map(|(name, count)| (count > 1).then_some(name))
.collect();
// Each defn is built independently: nested INSERTs are stored as
// by-name references (`LocalSub::Nested`), never expanded here, so a
// block's build never depends on another block's defn. That makes the
@@ -269,7 +304,6 @@ impl BlockCache {
/// Walk all entities + all block_record contents collecting every distinct
/// `block_name` that appears in an Insert (transitively).
fn collect_referenced_blocks(doc: &CadDocument) -> Vec<String> {
use rustc_hash::FxHashSet as HashSet;
let mut seen: HashSet<String> = HashSet::default();
let mut queue: Vec<String> = Vec::new();
@@ -795,6 +829,61 @@ pub fn expand_insert(
xform = xform.then(&scale_about_p);
}
let name = ins_handle.value().to_string();
let prototype_key = if view_aabb.is_none()
&& world_per_pixel.is_none()
&& !ins.is_array()
&& cache.prototype_blocks.contains(&ins.block_name)
{
Some(expansion_prototype_key(
ins,
&xform,
ins_resolved_color,
ins_pat_len,
ins_pat,
ins_lw_px,
ins_layer,
selected,
pslt_factor,
is_xref,
bg_color,
anno_scale,
))
} else {
None
};
let prototype_slot = prototype_key.as_ref().map(|key| {
let mut prototypes = cache
.expansion_prototypes
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
Arc::clone(
prototypes
.entry(key.clone())
.or_insert_with(|| Arc::new(std::sync::Mutex::new(None))),
)
});
let mut prototype_guard = prototype_slot
.as_ref()
.map(|slot| slot.lock().unwrap_or_else(|poisoned| poisoned.into_inner()));
if let Some(cached) = prototype_guard
.as_ref()
.and_then(|guard| guard.as_ref())
.cloned()
{
let translation = transform_translation(&xform);
let delta = [
translation[0] - cached.translation[0],
translation[1] - cached.translation[1],
translation[2] - cached.translation[2],
];
return Some(
cached
.wires
.iter()
.map(|wire| translated_prototype_wire(wire, &name, delta))
.collect(),
);
}
let mut batches = Batches::default();
let mut visited: Vec<String> = Vec::with_capacity(8);
@@ -848,7 +937,135 @@ pub fn expand_insert(
};
expand_defn(defn, &base_xform, &ctx, &mut batches, &mut visited, 0, (0.0, 1.0));
}
Some(batches.finalize(&name, selected, bg_color))
let wires = batches.finalize(&name, selected, bg_color);
if let Some(guard) = prototype_guard.as_mut() {
let cached = Arc::new(CachedExpansion {
translation: transform_translation(&xform),
wires: Arc::new(wires.clone()),
});
**guard = Some(cached);
}
Some(wires)
}
fn transform_translation(transform: &Transform) -> [f64; 3] {
[
transform.matrix.m[0][3],
transform.matrix.m[1][3],
transform.matrix.m[2][3],
]
}
#[allow(clippy::too_many_arguments)]
fn expansion_prototype_key(
ins: &acadrust::entities::Insert,
transform: &Transform,
ins_color: [f32; 4],
ins_pat_len: f32,
ins_pat: [f32; 8],
ins_lw_px: f32,
ins_layer: crate::scene::view::render::InheritStyle,
selected: bool,
pslt_factor: f32,
is_xref: bool,
bg_color: [f32; 4],
anno_scale: f32,
) -> ExpansionPrototypeKey {
let matrix = &transform.matrix.m;
let linear = [
matrix[0][0].to_bits(),
matrix[0][1].to_bits(),
matrix[0][2].to_bits(),
matrix[1][0].to_bits(),
matrix[1][1].to_bits(),
matrix[1][2].to_bits(),
matrix[2][0].to_bits(),
matrix[2][1].to_bits(),
matrix[2][2].to_bits(),
];
let mut insert_style = Vec::with_capacity(32);
insert_style.extend(ins_color.map(f32::to_bits));
insert_style.push(ins_pat_len.to_bits());
insert_style.extend(ins_pat.map(f32::to_bits));
insert_style.push(ins_lw_px.to_bits());
insert_style.extend(ins_layer.color.map(f32::to_bits));
insert_style.push(ins_layer.pat_len.to_bits());
insert_style.extend(ins_layer.pat.map(f32::to_bits));
insert_style.push(ins_layer.lw_px.to_bits());
insert_style.push(pslt_factor.to_bits());
insert_style.extend(bg_color.map(f32::to_bits));
insert_style.push(anno_scale.to_bits());
ExpansionPrototypeKey {
block_name: ins.block_name.clone(),
linear,
insert_style,
selected,
is_xref,
}
}
fn translated_prototype_wire(source: &WireModel, name: &str, delta: [f64; 3]) -> WireModel {
let mut wire = source.clone();
wire.name = name.to_string();
translate_double_single(&mut wire.points, &mut wire.points_low, delta);
translate_double_single(&mut wire.fill_tris, &mut wire.fill_tris_low, delta);
translate_double_single(&mut wire.pick_tris, &mut wire.pick_tris_low, delta);
for (point, _) in &mut wire.snap_pts {
point.x += delta[0];
point.y += delta[1];
point.z += delta[2];
}
for point in &mut wire.key_vertices {
point[0] += delta[0];
point[1] += delta[1];
point[2] += delta[2];
}
let delta_f32 = [delta[0] as f32, delta[1] as f32, delta[2] as f32];
for tangent in &mut wire.tangent_geoms {
match tangent {
TangentGeom::Line { p1, p2 } => {
for axis in 0..3 {
p1[axis] += delta_f32[axis];
p2[axis] += delta_f32[axis];
}
}
TangentGeom::Circle { center, .. } => {
for axis in 0..3 {
center[axis] += delta_f32[axis];
}
}
}
}
if !wire.text_verts.is_empty() {
wire.text_verts =
crate::scene::model::wire_model::map_text_verts(&wire.text_verts, |x, y, z| {
(x + delta[0], y + delta[1], z + delta[2])
});
}
if wire.aabb != WireModel::UNBOUNDED_AABB {
wire.aabb[0] += delta_f32[0];
wire.aabb[1] += delta_f32[1];
wire.aabb[2] += delta_f32[0];
wire.aabb[3] += delta_f32[1];
}
wire
}
fn translate_double_single(points: &mut [[f32; 3]], lows: &mut Vec<[f32; 3]>, delta: [f64; 3]) {
if points.is_empty() {
return;
}
if lows.len() != points.len() {
lows.resize(points.len(), [0.0; 3]);
}
for (point, low) in points.iter_mut().zip(lows.iter_mut()) {
for axis in 0..3 {
let value = point[axis] as f64 + low[axis] as f64 + delta[axis];
let high = value as f32;
point[axis] = high;
low[axis] = (value - high as f64) as f32;
}
}
}
fn aabb_pixel_size(local_aabb: [f32; 4], world_per_pixel: f32) -> f32 {
+4
View File
@@ -199,6 +199,7 @@ impl Scene {
};
if !handle.is_null() {
self.invalidate_dependency_index();
if let Some(model) = hatch_seed {
self.hatches.insert(handle, model);
}
@@ -275,6 +276,7 @@ impl Scene {
}
}
}
self.invalidate_dependency_index();
self.bump_geometry();
true
}
@@ -353,6 +355,7 @@ impl Scene {
return false;
};
*slot = entity;
self.invalidate_dependency_index();
// Drop stale derived caches for this handle, then reseed for the new
// entity's type (which may differ from the old one).
@@ -2092,6 +2095,7 @@ impl Scene {
/// Rebuild hatch / image / mesh caches after the document is modified
/// outside the normal `add_entity` path (e.g. REFCLOSE SAVE).
pub fn rebuild_derived_caches(&mut self) {
self.invalidate_dependency_index();
self.populate_hatches_from_document_unbumped();
self.populate_images_from_document_unbumped();
self.populate_meshes_impl(false, false);
+471 -38
View File
@@ -45,6 +45,22 @@ pub(super) struct EntityIndex {
pub unbounded_handles: Vec<Handle>,
}
#[derive(Default)]
struct DependencyTargets {
render_handles: HashSet<Handle>,
source_handles: HashSet<Handle>,
touches_block_definition: bool,
}
#[derive(Default)]
struct SceneDependencyIndex {
layers: HashMap<String, DependencyTargets>,
text_styles: HashMap<String, DependencyTargets>,
dim_styles: HashMap<String, DependencyTargets>,
object_styles: HashMap<Handle, DependencyTargets>,
blocks: HashMap<String, HashSet<Handle>>,
}
fn hatch_interaction_aabb(hatch: &model::hatch_model::HatchModel) -> Option<[f64; 4]> {
let mut aabb = [
f64::INFINITY,
@@ -238,6 +254,21 @@ struct ResidentWireLayout {
marker_start: usize,
}
#[derive(Clone)]
pub struct PreparedOpenGeometry {
pub wires: Arc<Vec<WireModel>>,
pub interaction_index: Option<Arc<crate::scene::pick::interaction_index::InteractionIndex>>,
}
impl std::fmt::Debug for PreparedOpenGeometry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PreparedOpenGeometry")
.field("wires", &self.wires.len())
.field("interaction_index", &self.interaction_index.is_some())
.finish()
}
}
#[derive(Debug)]
pub(crate) struct WireGpuPatch {
pub(crate) changes: Arc<Vec<(Handle, ChangeKind)>>,
@@ -357,6 +388,15 @@ pub struct DerivedCaches {
/// Reported back to the UI so the user knows when a file had parser-junk
/// entities silently dropped.
pub corrupt_dropped: usize,
/// Corrupt entities dropped while resolving referenced drawings.
pub xref_dropped: usize,
/// XREF resolution results produced by the loader worker. Keeping these in
/// the open bundle prevents parsing and merging references on the UI thread.
pub xrefs: Vec<crate::io::xref::XrefInfo>,
/// Model wire set and its spatial interaction index, prepared on the loader
/// thread. Installing these prevents the first visible frame from paying a
/// whole-drawing tessellation/index build while the progress overlay freezes.
pub prepared_geometry: Option<PreparedOpenGeometry>,
/// Background-thread open-phase timings in milliseconds (parse, purge,
/// derived-cache build). Filled in by `open_path_with_phase`; surfaced in
/// the open-complete breakdown log so open-time regressions are visible.
@@ -369,11 +409,30 @@ pub struct OpenTimings {
pub parse_ms: u32,
pub purge_ms: u32,
pub caches_ms: u32,
pub xref_ms: u32,
}
/// Build hatch / image / mesh caches from a document without needing `&mut Scene`.
/// Intended to run on a background thread during file load.
pub fn build_derived_caches(doc: &CadDocument) -> DerivedCaches {
build_derived_caches_impl(doc, None)
}
/// Build open-time caches while reporting monotonic progress in 0..=10000.
///
/// The callback is UI-agnostic and may run from Rayon workers. Callers should
/// keep it cheap, normally just updating atomics.
pub fn build_derived_caches_with_progress(
doc: &CadDocument,
progress: &(dyn Fn(u16) + Sync),
) -> DerivedCaches {
build_derived_caches_impl(doc, Some(progress))
}
fn build_derived_caches_impl(
doc: &CadDocument,
progress: Option<&(dyn Fn(u16) + Sync)>,
) -> DerivedCaches {
// A new drawing must not inherit the previous one's resolved images — drop
// the memoised set so each reference re-reads / re-fetches once here (and
// stays cached across this document's later cache rebuilds).
@@ -428,7 +487,8 @@ pub fn build_derived_caches(doc: &CadDocument) -> DerivedCaches {
let mut image_handles: Vec<Handle> = Vec::new();
let mut mesh_handles: Vec<Handle> = Vec::new();
let mut centers: Vec<[f64; 3]> = Vec::new();
for e in doc.entities() {
let entity_total = doc.entity_count().max(1);
for (index, e) in doc.entities().enumerate() {
let h = e.common().handle;
match e {
EntityType::Hatch(_) | EntityType::Solid(_) => hatch_handles.push(h),
@@ -444,6 +504,14 @@ pub fn build_derived_caches(doc: &CadDocument) -> DerivedCaches {
if let Some(c) = offset_centroid(e, model_block, &prep) {
centers.push(c);
}
if index & 0x1fff == 0 {
if let Some(progress) = progress {
progress(((index as u64 * 4000) / entity_total as u64) as u16);
}
}
}
if let Some(progress) = progress {
progress(4000);
}
let (local_center, local_extent_max) = cluster_extent_from_centers(centers, &doc.header);
@@ -453,6 +521,19 @@ pub fn build_derived_caches(doc: &CadDocument) -> DerivedCaches {
// the per-layout adaptation kicks in later regardless).
const LOAD_BG: [f32; 4] = [33.0 / 255.0, 40.0 / 255.0, 48.0 / 255.0, 1.0];
let detail_total = hatch_handles
.len()
.saturating_add(image_handles.len())
.saturating_add(mesh_handles.len())
.max(1);
let detail_done = std::sync::atomic::AtomicUsize::new(0);
let report_detail = |done: usize| {
if let Some(progress) = progress {
let value = 4000u64 + done as u64 * 6000 / detail_total as u64;
progress(value.min(10000) as u16);
}
};
// hatches
let hatches: HashMap<Handle, HatchModel> = hatch_handles
.par_iter()
@@ -465,15 +546,23 @@ pub fn build_derived_caches(doc: &CadDocument) -> DerivedCaches {
EntityType::Solid(solid) => Some(Scene::solid_hatch_model(solid, color)),
_ => None,
};
model.map(|m| (handle, m))
let result = model.map(|m| (handle, m));
let done = detail_done.fetch_add(1, Ordering::Relaxed) + 1;
if done & 0xff == 0 || done == detail_total {
report_detail(done);
}
result
})
.collect();
// images
let images: HashMap<Handle, ImageModel> = image_handles
.par_iter()
.filter_map(|&handle| match doc.get_entity(handle)? {
EntityType::RasterImage(img) => ImageModel::from_raster_image(img).map(|m| (handle, m)),
.filter_map(|&handle| {
let result = match doc.get_entity(handle)? {
EntityType::RasterImage(img) => {
ImageModel::from_raster_image(img).map(|m| (handle, m))
}
EntityType::Ole2Frame(ole) => ImageModel::from_ole2frame(ole).map(|m| (handle, m)),
EntityType::Underlay(u) => match doc.objects.get(&u.definition_handle) {
Some(acadrust::objects::ObjectType::UnderlayDefinition(def)) => {
@@ -482,6 +571,12 @@ pub fn build_derived_caches(doc: &CadDocument) -> DerivedCaches {
_ => None,
},
_ => None,
};
let done = detail_done.fetch_add(1, Ordering::Relaxed) + 1;
if done & 0xff == 0 || done == detail_total {
report_detail(done);
}
result
})
.collect();
@@ -511,10 +606,16 @@ pub fn build_derived_caches(doc: &CadDocument) -> DerivedCaches {
let (raw, ..) = view::render::render_style_for(doc, e);
let color = view::render::adapt_to_bg(raw, LOAD_BG);
let top_level = layout_blocks.contains(&e.common().owner_handle);
crate::entities::solid3d::tessellate_volume(e, color, facet_res, isolines).map(|m| {
let result = crate::entities::solid3d::tessellate_volume(e, color, facet_res, isolines)
.map(|m| {
let m = if top_level { offset_mesh_lod_set(m) } else { m };
(handle, m, top_level)
})
});
let done = detail_done.fetch_add(1, Ordering::Relaxed) + 1;
if done & 0xff == 0 || done == detail_total {
report_detail(done);
}
result
})
.collect();
let mut meshes: HashMap<Handle, MeshLodSet> = HashMap::default();
@@ -527,6 +628,10 @@ pub fn build_derived_caches(doc: &CadDocument) -> DerivedCaches {
}
}
if let Some(progress) = progress {
progress(10000);
}
DerivedCaches {
local_extent_max,
local_center,
@@ -535,10 +640,54 @@ pub fn build_derived_caches(doc: &CadDocument) -> DerivedCaches {
meshes,
block_meshes,
corrupt_dropped: 0,
xref_dropped: 0,
xrefs: Vec::new(),
prepared_geometry: None,
timings: OpenTimings::default(),
}
}
/// Prepare the expensive first Model wire set and spatial interaction index on
/// the loader thread. The temporary `Scene` never crosses threads (it contains
/// `Rc`/`RefCell` state); only its Send-safe document and immutable prepared
/// geometry are returned.
pub fn prepare_open_geometry(
doc: CadDocument,
caches: &DerivedCaches,
model_bg: [f32; 4],
) -> (CadDocument, PreparedOpenGeometry) {
let mut scene = Scene::new();
scene.document = doc;
scene.local_extent_max = caches.local_extent_max;
scene.local_center = caches.local_center;
scene.bg_color = model_bg;
let cannoscale_value = scene.document.header.annotation_scale_value;
scene.annotation_scale = if cannoscale_value > 1e-9 {
(1.0 / cannoscale_value) as f32
} else {
1.0
};
scene.current_layout = "Model".to_string();
let camera = scene.camera.borrow().clone();
let wires = scene.model_tile_wires_arc(0, &camera, 1.0, 1.0);
let interaction_index = if scene.interaction_index_worthwhile(&wires) {
let index =
Arc::new(crate::scene::pick::interaction_index::InteractionIndex::build(&wires));
index.prepare_screen();
Some(index)
} else {
None
};
let doc = std::mem::replace(&mut scene.document, CadDocument::new());
(
doc,
PreparedOpenGeometry {
wires,
interaction_index,
},
)
}
/// Mirrors `cache::block_cache::SANE_EXTENT` — wire coords past this magnitude
/// are treated as corruption rather than precision-relevant geometry.
const CLUSTER_SANE_EXTENT: f64 = 1.0e8;
@@ -1165,13 +1314,10 @@ pub struct Scene {
/// filtered variants. Viewports sharing a frozen set share one entry (like
/// the resident wire set). Empty for a viewport with no frozen layers (it
/// reuses the unfiltered `*_arc` sets directly).
frozen_hatch_cache:
RefCell<HashMap<(String, u64), (u64, u64, Arc<Vec<HatchModel>>)>>,
frozen_wipeout_cache:
RefCell<HashMap<(String, u64), (u64, Arc<Vec<HatchModel>>)>>,
frozen_hatch_cache: RefCell<HashMap<(String, u64), (u64, u64, Arc<Vec<HatchModel>>)>>,
frozen_wipeout_cache: RefCell<HashMap<(String, u64), (u64, Arc<Vec<HatchModel>>)>>,
frozen_image_cache: RefCell<HashMap<u64, (u64, Arc<Vec<ImageModel>>)>>,
frozen_mesh_cache:
RefCell<HashMap<(String, u64), (u64, Arc<Vec<MeshLodSet>>)>>,
frozen_mesh_cache: RefCell<HashMap<(String, u64), (u64, Arc<Vec<MeshLodSet>>)>>,
/// Cached block-INSERT hatches for hit-testing, keyed by geometry_epoch.
/// Building this explodes every model-space INSERT, so without the cache a
/// heavy block-instanced drawing re-explodes thousands of inserts on every
@@ -1258,12 +1404,16 @@ pub struct Scene {
/// Reverse map: entity_handle → block_record_handle, built from entity_handles lists.
/// Keyed by geometry_epoch. Eliminates the O(B) fallback scan in belongs_to_visible_block.
entity_block_map_cache: RefCell<Option<(u64, HashMap<Handle, Handle>)>>,
/// Reverse dependencies from layer/style/block definitions to the top-level
/// entities whose resident wire runs actually change. Kept independent from
/// `geometry_epoch`: a layer colour toggle can reuse the index, invalidate
/// only its dependants, and avoid a whole-document scan on every toggle.
dependency_index_cache: RefCell<Option<SceneDependencyIndex>>,
/// Tessellated block definitions in block-local coords, keyed by render
/// background and block epoch. Model and Paper adapt black/white colours
/// differently; retaining both variants prevents a full block rebuild on
/// every layout-tab switch.
block_defn_cache:
RefCell<HashMap<[u32; 4], (u64, Arc<cache::block_cache::BlockCache>)>>,
block_defn_cache: RefCell<HashMap<[u32; 4], (u64, Arc<cache::block_cache::BlockCache>)>>,
/// Spatial index + always-emit list for top-level entities
/// (Phase 2.1). Lazily rebuilt by `entity_index()` on
/// `geometry_epoch` change. See `EntityIndex` for what each side
@@ -1455,6 +1605,7 @@ impl Scene {
annotation_affects_wires: std::cell::Cell::new(None),
model_extents_cache: RefCell::new(None),
entity_block_map_cache: RefCell::new(None),
dependency_index_cache: RefCell::new(None),
block_defn_cache: RefCell::new(HashMap::default()),
entity_index_cache: RefCell::new(None),
last_render_aspect: std::cell::Cell::new(16.0 / 9.0),
@@ -1555,6 +1706,30 @@ impl Scene {
arc
}
/// Install loader-thread geometry into this scene's Model resident cache.
///
/// The target scene has a different epoch from the temporary loader scene,
/// so only immutable geometry is transferred and re-stamped here. The
/// optional interaction index is cached against the exact same `Arc`.
pub fn install_prepared_open_geometry(&self, prepared: PreparedOpenGeometry) {
let block = self.model_space_block_handle();
let key = Self::resident_wire_key(block, self.bg_color, None, None);
let gen = WIRE_CONTENT_GEN.fetch_add(1, Ordering::Relaxed);
self.last_model_wire_gen.set(gen);
self.resident_wire_sets.borrow_mut().insert(
key,
ResidentWireSet {
epoch: self.geometry_epoch,
gen,
wires: Arc::clone(&prepared.wires),
layout: None,
},
);
if let Some(index) = prepared.interaction_index {
self.cache_interaction_index(self.geometry_epoch, prepared.wires, index);
}
}
/// Push one delta onto the journal, evicting the oldest past the cap and
/// raising the floor so a consumer that fell behind falls back to a full
/// rebuild. Every `geometry_epoch` bump must call this exactly once so the
@@ -1779,6 +1954,10 @@ impl Scene {
}
}
if !changes.is_empty() {
self.invalidate_dependency_index();
}
changes
}
@@ -3245,30 +3424,7 @@ impl Scene {
} else {
self.paper_bg_color
};
let key = {
let mut k: u64 = 0xcbf2_9ce4_8422_2325;
let mut mix = |x: u64| k = k.rotate_left(17) ^ x.wrapping_mul(0x9E37_79B9_7F4A_7C15);
mix(block.value());
for c in bg {
mix(c.to_bits() as u64);
}
mix(anno_scale_override
.map(|a| a.to_bits() as u64)
.unwrap_or(u64::MAX));
match frozen_layers {
Some(f) => {
// Order-independent fold of the frozen-layer set.
let mut acc: u64 = 0;
for h in f {
acc ^= h.value().wrapping_mul(0x9E37_79B9_7F4A_7C15);
}
mix(acc);
mix(f.len() as u64);
}
None => mix(u64::MAX - 1),
}
k
};
let key = Self::resident_wire_key(block, bg, anno_scale_override, frozen_layers);
{
let sets = self.resident_wire_sets.borrow();
if let Some(set) = sets.get(&key) {
@@ -3331,6 +3487,36 @@ impl Scene {
arc
}
fn resident_wire_key(
block: Handle,
bg: [f32; 4],
anno_scale_override: Option<f32>,
frozen_layers: Option<&HashSet<Handle>>,
) -> u64 {
let mut key: u64 = 0xcbf2_9ce4_8422_2325;
let mut mix =
|value: u64| key = key.rotate_left(17) ^ value.wrapping_mul(0x9E37_79B9_7F4A_7C15);
mix(block.value());
for component in bg {
mix(component.to_bits() as u64);
}
mix(anno_scale_override
.map(|scale| scale.to_bits() as u64)
.unwrap_or(u64::MAX));
match frozen_layers {
Some(frozen) => {
let mut signature = 0u64;
for handle in frozen {
signature ^= handle.value().wrapping_mul(0x9E37_79B9_7F4A_7C15);
}
mix(signature);
mix(frozen.len() as u64);
}
None => mix(u64::MAX - 1),
}
key
}
/// The GPU wire-arena handoff for a viewport whose content id is `gen`:
/// `(prev_gen, changed handles)` when the Model set reached `gen` via an
/// incremental resident patch, else `None`. Read (not consumed) so every
@@ -6375,6 +6561,253 @@ impl Scene {
})
}
fn rebuild_dependency_index(&self) -> SceneDependencyIndex {
let layout_blocks: HashSet<Handle> = self
.document
.objects
.values()
.filter_map(|object| match object {
acadrust::objects::ObjectType::Layout(layout) if !layout.block_record.is_null() => {
Some(layout.block_record)
}
_ => None,
})
.collect();
let block_names: HashMap<Handle, String> = self
.document
.block_records
.iter()
.map(|record| (record.handle, record.name.to_ascii_uppercase()))
.collect();
let membership: HashMap<Handle, Handle> = self
.document
.block_records
.iter()
.flat_map(|record| {
record
.entity_handles
.iter()
.copied()
.map(move |handle| (handle, record.handle))
})
.collect();
let mut roots: HashMap<String, HashSet<Handle>> = HashMap::default();
let mut parents: HashMap<String, HashSet<String>> = HashMap::default();
for entity in self.document.entities() {
let EntityType::Insert(insert) = entity else {
continue;
};
let target = insert.block_name.to_ascii_uppercase();
let common = &insert.common;
let owner = if common.owner_handle.is_null() {
membership
.get(&common.handle)
.copied()
.unwrap_or(Handle::NULL)
} else {
common.owner_handle
};
if owner.is_null() || layout_blocks.contains(&owner) {
roots.entry(target).or_default().insert(common.handle);
} else if let Some(parent) = block_names.get(&owner) {
parents.entry(target).or_default().insert(parent.clone());
}
}
// Propagate top-level INSERT users through nested block references.
// Fixed-point form is cycle-safe and block graphs are normally shallow.
let mut changed = true;
while changed {
changed = false;
for (child, parent_names) in &parents {
let inherited: Vec<Handle> = parent_names
.iter()
.flat_map(|parent| roots.get(parent).into_iter().flatten().copied())
.collect();
let entry = roots.entry(child.clone()).or_default();
let before = entry.len();
entry.extend(inherited);
changed |= entry.len() != before;
}
}
let mut index = SceneDependencyIndex {
blocks: roots.clone(),
..SceneDependencyIndex::default()
};
for entity in self.document.entities() {
let common = entity.common();
let owner = if common.owner_handle.is_null() {
membership
.get(&common.handle)
.copied()
.unwrap_or(Handle::NULL)
} else {
common.owner_handle
};
let inside_block = !owner.is_null() && !layout_blocks.contains(&owner);
let render_handles: HashSet<Handle> = if inside_block {
block_names
.get(&owner)
.and_then(|name| roots.get(name))
.cloned()
.unwrap_or_default()
} else {
std::iter::once(common.handle).collect()
};
let add = |map: &mut HashMap<String, DependencyTargets>, name: &str| {
let target = map.entry(name.to_ascii_uppercase()).or_default();
target.render_handles.extend(render_handles.iter().copied());
target.source_handles.insert(common.handle);
target.touches_block_definition |= inside_block;
};
let add_handle = |map: &mut HashMap<Handle, DependencyTargets>,
handle: Option<Handle>| {
let Some(handle) = handle.filter(|handle| !handle.is_null()) else {
return;
};
let target = map.entry(handle).or_default();
target.render_handles.extend(render_handles.iter().copied());
target.source_handles.insert(common.handle);
target.touches_block_definition |= inside_block;
};
add(&mut index.layers, &common.layer);
match entity {
EntityType::Text(text) => add(&mut index.text_styles, &text.style),
EntityType::MText(text) => add(&mut index.text_styles, &text.style),
EntityType::AttributeDefinition(attribute) => {
add(&mut index.text_styles, &attribute.text_style)
}
EntityType::AttributeEntity(attribute) => {
add(&mut index.text_styles, &attribute.text_style)
}
EntityType::Insert(insert) => {
for attribute in &insert.attributes {
add(&mut index.text_styles, &attribute.text_style);
}
}
EntityType::Dimension(dimension) => {
add(&mut index.dim_styles, &dimension.base().style_name)
}
EntityType::Table(table) => {
add_handle(&mut index.object_styles, table.table_style_handle)
}
EntityType::MultiLeader(leader) => {
add_handle(&mut index.object_styles, leader.style_handle)
}
EntityType::MLine(line) => add_handle(&mut index.object_styles, line.style_handle),
_ => {}
}
}
index
}
fn dependency_targets(&self, kind: &str, names: &[String]) -> DependencyTargets {
if self.dependency_index_cache.borrow().is_none() {
*self.dependency_index_cache.borrow_mut() = Some(self.rebuild_dependency_index());
}
let cache = self.dependency_index_cache.borrow();
let index = cache.as_ref().unwrap();
let map = match kind {
"layer" => &index.layers,
"text" => &index.text_styles,
"dim" => &index.dim_styles,
_ => unreachable!(),
};
let mut combined = DependencyTargets::default();
for name in names {
let Some(target) = map.get(&name.to_ascii_uppercase()) else {
continue;
};
combined
.render_handles
.extend(target.render_handles.iter().copied());
combined
.source_handles
.extend(target.source_handles.iter().copied());
combined.touches_block_definition |= target.touches_block_definition;
}
combined
}
fn invalidate_dependency_targets(&mut self, targets: DependencyTargets) {
if targets.render_handles.is_empty() {
return;
}
if targets.touches_block_definition {
self.block_epoch = GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed);
}
let sources: Vec<Handle> = targets.source_handles.iter().copied().collect();
self.recolor_meshes_for_handles(&sources);
let changes: Vec<(Handle, ChangeKind)> = targets
.render_handles
.into_iter()
.map(|handle| (handle, ChangeKind::Modified))
.collect();
self.bump_entities(&changes);
}
pub fn invalidate_layer_dependencies(&mut self, names: &[String]) {
let targets = self.dependency_targets("layer", names);
self.invalidate_dependency_targets(targets);
}
pub fn invalidate_text_style_dependencies(&mut self, name: &str) {
self.invalidate_text_style_dependencies_many(&[name.to_string()]);
}
pub fn invalidate_text_style_dependencies_many(&mut self, names: &[String]) {
let targets = self.dependency_targets("text", names);
self.invalidate_dependency_targets(targets);
}
pub fn invalidate_dim_style_dependencies(&mut self, name: &str) {
self.invalidate_dim_style_dependencies_many(&[name.to_string()]);
}
pub fn invalidate_dim_style_dependencies_many(&mut self, names: &[String]) {
let targets = self.dependency_targets("dim", names);
self.invalidate_dependency_targets(targets);
}
pub fn invalidate_object_style_dependencies(&mut self, handles: &[Handle]) {
if self.dependency_index_cache.borrow().is_none() {
*self.dependency_index_cache.borrow_mut() = Some(self.rebuild_dependency_index());
}
let mut combined = DependencyTargets::default();
if let Some(index) = self.dependency_index_cache.borrow().as_ref() {
for handle in handles {
let Some(target) = index.object_styles.get(handle) else {
continue;
};
combined
.render_handles
.extend(target.render_handles.iter().copied());
combined
.source_handles
.extend(target.source_handles.iter().copied());
combined.touches_block_definition |= target.touches_block_definition;
}
}
self.invalidate_dependency_targets(combined);
}
pub fn block_dependency_handles(&self, name: &str) -> Vec<Handle> {
if self.dependency_index_cache.borrow().is_none() {
*self.dependency_index_cache.borrow_mut() = Some(self.rebuild_dependency_index());
}
self.dependency_index_cache
.borrow()
.as_ref()
.and_then(|index| index.blocks.get(&name.to_ascii_uppercase()))
.map(|handles| handles.iter().copied().collect())
.unwrap_or_default()
}
pub(crate) fn invalidate_dependency_index(&self) {
self.dependency_index_cache.borrow_mut().take();
}
/// Spatial index + always-emit list for top-level entities. Lazily
/// rebuilt on `geometry_epoch` change.
///
+76 -7
View File
@@ -47,7 +47,7 @@ pub struct Pipeline {
/// Used to draw ghost copies of selected wires through occluding geometry.
wire_xray_pipeline: wgpu::RenderPipeline,
/// Layout for the per-wire `WireConst` storage buffer (group 1 of the wire /
/// xray pipelines). `Some` only on the fast native path; `None` in packed
/// xray pipelines). `Some` on native/WebGPU fast paths; `None` in packed
/// compatibility mode. Passed to `WireGpu::from_run` / `from_batch`.
pub(crate) wire_const_bgl: Option<wgpu::BindGroupLayout>,
wipeout_pipeline: wgpu::RenderPipeline,
@@ -338,8 +338,8 @@ impl Pipeline {
});
// ── Wire pipeline ──────────────────────────────────────────────────
// Select once per device. The fast native path hoists shared constants
// into storage; compatibility mode keeps them in 10 packed attributes.
// Select once per device. Native and WebGPU hoist shared constants into
// storage when limits allow; WebGL2/compat keeps packed attributes.
let wire_mode = wire_gpu::WirePipelineMode::select(device);
let renderer_mode_name =
if wire_mode.uses_storage() { "fast-storage" } else { "packed-compat" };
@@ -357,12 +357,9 @@ impl Pipeline {
renderer_mode_name,
device.limits().max_storage_buffers_per_shader_stage
);
#[cfg(not(target_arch = "wasm32"))]
let wire_const_bgl = wire_mode
.uses_storage()
.then(|| wire_gpu::WireConst::bind_group_layout(device));
#[cfg(target_arch = "wasm32")]
let wire_const_bgl: Option<wgpu::BindGroupLayout> = None;
let mut wire_bgls: Vec<&wgpu::BindGroupLayout> = vec![&frame_bgl];
if let Some(bgl) = &wire_const_bgl {
wire_bgls.push(bgl);
@@ -379,7 +376,6 @@ impl Pipeline {
let wire_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("wire.shader"),
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(match wire_mode {
#[cfg(not(target_arch = "wasm32"))]
wire_gpu::WirePipelineMode::IndexedStorage => {
include_str!("../../shaders/wire_indexed.wgsl")
}
@@ -3157,6 +3153,14 @@ fn create_msaa_texture(
pub struct MultiPipeline {
pub(crate) inners: Vec<Pipeline>,
format: wgpu::TextureFormat,
/// Stable viewport identity → pipeline slot map. Paper viewports used to
/// occupy slots by their current list position, so switching layouts or
/// scrolling a sheet could assign an existing viewport to a different
/// slot and throw away all of its GPU caches. Keep the association across
/// tab switches and only recycle genuinely cold slots.
pub(crate) slot_by_instance: rustc_hash::FxHashMap<u64, usize>,
slot_last_used: Vec<u64>,
slot_clock: u64,
/// The resident wire batches, keyed by `wire_content_id` and shared across
/// every slot (and every pane — one `MultiPipeline` backs all of them) that
/// renders the same content. `prepare` builds an entry once on a cache miss
@@ -3189,8 +3193,70 @@ impl MultiPipeline {
let n = n.max(1);
while self.inners.len() < n {
self.inners.push(Pipeline::new(device, queue, self.format));
self.slot_last_used.push(0);
}
}
/// Resolve stable slots for the viewport identities in one primitive.
/// Thirty-two hot slots cover ordinary tiled/paper drawings. A cold slot
/// is recycled only after several other prepare calls, which prevents
/// sibling Model panes prepared in the same frame from evicting each
/// other. If every slot is still hot, growing is safer than a visible
/// rebuild hitch.
pub(crate) fn resolve_slots(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
instance_ids: &[u64],
) -> Vec<usize> {
const SOFT_LIMIT: usize = 32;
const HOT_WINDOW: u64 = 8;
self.slot_clock = self.slot_clock.wrapping_add(1).max(1);
let now = self.slot_clock;
let reserved: rustc_hash::FxHashSet<u64> = instance_ids.iter().copied().collect();
let mut slots = Vec::with_capacity(instance_ids.len());
for &instance_id in instance_ids {
let slot = if let Some(&slot) = self.slot_by_instance.get(&instance_id) {
slot
} else {
let vacant = self
.inners
.iter()
.position(|inner| inner.slot_id == u64::MAX);
let recyclable = vacant.or_else(|| {
(self.inners.len() >= SOFT_LIMIT)
.then(|| {
self.inners
.iter()
.enumerate()
.filter(|(_, inner)| !reserved.contains(&inner.slot_id))
.filter(|(slot, _)| {
now.saturating_sub(self.slot_last_used[*slot]) > HOT_WINDOW
})
.min_by_key(|(slot, _)| self.slot_last_used[*slot])
.map(|(slot, _)| slot)
})
.flatten()
});
let slot = recyclable.unwrap_or_else(|| {
let slot = self.inners.len();
self.ensure_len(device, queue, slot + 1);
slot
});
let old_id = self.inners[slot].slot_id;
if old_id != u64::MAX {
self.slot_by_instance.remove(&old_id);
}
self.slot_by_instance.insert(instance_id, slot);
slot
};
self.slot_last_used[slot] = now;
slots.push(slot);
}
slots
}
}
/// Send wgpu's uncaptured validation errors to stderr instead of the default
@@ -3227,6 +3293,9 @@ impl iced::widget::shader::Pipeline for MultiPipeline {
Self {
inners: vec![Pipeline::new(device, queue, format)],
format,
slot_by_instance: rustc_hash::FxHashMap::default(),
slot_last_used: vec![0],
slot_clock: 0,
wire_buffer_cache: rustc_hash::FxHashMap::default(),
}
}
+13 -38
View File
@@ -58,18 +58,17 @@ fn instance_buffer_mapped<T: bytemuck::Pod>(
// ── Instance layout ───────────────────────────────────────────────────────
// ── Native: slim per-segment instance + shared per-wire constants ───────────
// ── WebGPU/native: slim per-segment instance + shared constants ─────────────
//
// Every segment of a wire used to carry the wire's color / line-weight / dash
// pattern / draw-depth (~44 B) on each instance — re-fetched once per segment
// even though it's constant along the wire. On native we hoist those into a
// even though it's constant along the wire. On storage-capable adapters we hoist those into a
// per-wire `WireConst` storage buffer indexed by `wire_id`, so the instance
// keeps only the per-segment data (endpoints + arc-length distances). Cuts the
// instance from 104 B to one 64-byte cache line and removes the redundant
// per-segment re-fetch of the shared constants. WebGL2 has no vertex-stage
// storage buffers, so the wasm build below keeps the original self-contained
// fat instance.
#[cfg(not(target_arch = "wasm32"))]
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
pub struct WireInstance {
@@ -87,8 +86,6 @@ pub struct WireInstance {
/// exactly one 64-byte cache line.
pub taper_ratio: [u16; 2],
}
#[cfg(not(target_arch = "wasm32"))]
impl WireInstance {
pub fn layout<'a>() -> wgpu::VertexBufferLayout<'a> {
// Must match `InstanceIn` in wire_indexed.wgsl.
@@ -110,11 +107,10 @@ impl WireInstance {
}
}
/// Per-wire constants shared by every segment of a wire (native only). std430
/// Per-wire constants shared by every segment of a wire. std430
/// layout: three vec4 then eight scalars = 80 B, matching `WireConst` in
/// wire_indexed.wgsl. `align_end` / `align_total` carry the "A"-type endpoint
/// alignment (see `wire_distances`); 0.0 total = no alignment.
#[cfg(not(target_arch = "wasm32"))]
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
pub struct WireConst {
@@ -134,8 +130,6 @@ pub struct WireConst {
pub _pad1: f32,
pub _pad2: f32,
}
#[cfg(not(target_arch = "wasm32"))]
impl WireConst {
/// Bind-group layout for the per-wire storage buffer (group 1 of the wire /
/// xray pipelines). Read-only storage, visible to the vertex stage.
@@ -158,8 +152,8 @@ impl WireConst {
// ── Packed compatibility instance (no vertex-stage storage) ────────────────
//
// Web always uses this layout. Native selects it at runtime for adapters whose
// storage-buffer limits are insufficient, or when --compat-renderer is set.
// WebGL2 and limited native adapters use this layout. WebGPU selects the
// indexed storage path when its reported limits satisfy the same requirement.
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
pub struct PackedWireInstance {
@@ -224,21 +218,15 @@ impl PackedWireInstance {
}
}
#[cfg(target_arch = "wasm32")]
pub type WireInstance = PackedWireInstance;
/// Wire and hatch pipelines switch together: the fast path uses storage
/// buffers; the compatibility path carries wire constants in packed vertex
/// attributes and hatch data in a texture.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WirePipelineMode {
#[cfg(not(target_arch = "wasm32"))]
IndexedStorage,
Packed,
}
#[cfg(not(target_arch = "wasm32"))]
fn select_native_pipeline(max_storage_buffers_per_stage: u32, forced: bool) -> WirePipelineMode {
fn select_pipeline(max_storage_buffers_per_stage: u32, forced: bool) -> WirePipelineMode {
const REQUIRED_STORAGE_BUFFERS_PER_STAGE: u32 = 5;
if forced || max_storage_buffers_per_stage < REQUIRED_STORAGE_BUFFERS_PER_STAGE {
WirePipelineMode::Packed
@@ -249,23 +237,15 @@ fn select_native_pipeline(max_storage_buffers_per_stage: u32, forced: bool) -> W
impl WirePipelineMode {
pub fn select(device: &wgpu::Device) -> Self {
#[cfg(target_arch = "wasm32")]
{
let _ = device;
Self::Packed
}
#[cfg(not(target_arch = "wasm32"))]
{
select_native_pipeline(
device.limits().max_storage_buffers_per_shader_stage,
crate::cli::gui_config().compat_renderer,
)
}
let forced = crate::cli::gui_config().compat_renderer;
#[cfg(target_arch = "wasm32")]
let forced = false;
select_pipeline(device.limits().max_storage_buffers_per_shader_stage, forced)
}
pub fn uses_storage(self) -> bool {
match self {
#[cfg(not(target_arch = "wasm32"))]
Self::IndexedStorage => true,
Self::Packed => false,
}
@@ -273,7 +253,6 @@ impl WirePipelineMode {
pub fn layout<'a>(self) -> wgpu::VertexBufferLayout<'a> {
match self {
#[cfg(not(target_arch = "wasm32"))]
Self::IndexedStorage => WireInstance::layout(),
Self::Packed => PackedWireInstance::layout(),
}
@@ -494,9 +473,8 @@ fn emit_wire_packed(
instances
}
/// Native: emit slim per-segment instances (positions + distances + `wire_id`)
/// Storage path: emit slim per-segment instances (positions + distances + `wire_id`)
/// plus the one `WireConst` record every segment of this wire shares.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn emit_wire_native(
wire: &WireModel,
wire_id: u32,
@@ -579,11 +557,10 @@ pub(crate) fn wire_draw_depth(
}
}
/// Build the shared per-wire `WireConst` storage buffer and its bind group
/// (native only). All instance-buffer chunks from one build reference the same
/// Build the shared per-wire `WireConst` storage buffer and its bind group.
/// All instance-buffer chunks from one build reference the same
/// buffer via their global `wire_id`, so a single bind group is cloned into
/// each chunk.
#[cfg(not(target_arch = "wasm32"))]
fn build_const_bind_group(
device: &wgpu::Device,
bgl: &wgpu::BindGroupLayout,
@@ -677,7 +654,6 @@ impl WireGpu {
mesh_edge: bool,
const_bgl: Option<&wgpu::BindGroupLayout>,
) -> Vec<Self> {
#[cfg(not(target_arch = "wasm32"))]
if let Some(const_bgl) = const_bgl {
const MAX_INSTANCES: usize =
268_435_456 / std::mem::size_of::<WireInstance>();
@@ -769,7 +745,6 @@ impl WireGpu {
if total_segs == 0 {
return vec![];
}
#[cfg(not(target_arch = "wasm32"))]
if let Some(const_bgl) = const_bgl {
// GPU max buffer size is 256 MB; chunk to stay within the limit.
const MAX_INSTANCES: usize =
+5 -1
View File
@@ -391,7 +391,10 @@ impl Scene {
// Report the exact erased handles so derived caches drop just those and
// the resident set removes only their wires (bump_entities drops them
// from the tessellation memos too).
self.bump_entities(&erased);
if !erased.is_empty() {
self.invalidate_dependency_index();
self.bump_entities(&erased);
}
}
/// Restore erased Arc-backed entities without re-linking their still-present
@@ -414,6 +417,7 @@ impl Scene {
}
}
if !changes.is_empty() {
self.invalidate_dependency_index();
self.bump_entities(&changes);
}
restored
+12 -19
View File
@@ -152,13 +152,6 @@ pub struct Primitive {
pub(in crate::scene) viewports: Vec<ViewportData>,
/// Background color used to clear each viewport's MSAA buffer.
pub(in crate::scene) bg_color: [f32; 4],
/// First `MultiPipeline` inner slot this primitive owns. Paper space (one
/// shader widget, many viewports) uses 0. Per-pane Model widgets each own a
/// distinct slot (= their tile index) so several shader widgets can share
/// the type-keyed pipeline storage without clobbering one another — all
/// `prepare` calls run before all `render` calls, so disjoint slots are
/// safe.
pub(in crate::scene) base_slot: usize,
/// One input-to-render sample, carried only when PERF tracing is enabled.
pub(in crate::scene) nav_perf: Option<NavPerfSample>,
}
@@ -250,10 +243,11 @@ impl shader::Primitive for Primitive {
let phys = viewport.physical_size();
let full_size = Size::new(phys.width, phys.height);
let scale = viewport.scale_factor() as f32;
pipeline.ensure_len(device, queue, self.base_slot + self.viewports.len());
let instance_ids: Vec<u64> = self.viewports.iter().map(|vp| vp.instance_id).collect();
let slots = pipeline.resolve_slots(device, queue, &instance_ids);
for (i, vp) in self.viewports.iter().enumerate() {
let inner = &mut pipeline.inners[self.base_slot + i];
let inner = &mut pipeline.inners[slots[i]];
// Pipeline slots are addressed by list index, but off-canvas
// viewports are dropped from the list — so a slot can be reused by a
// DIFFERENT viewport across frames (e.g. the first viewport scrolls
@@ -442,7 +436,10 @@ impl shader::Primitive for Primitive {
// the whole wire buffer. Only for the scissor-free, mesh-free
// (single-batch) Model set; scissored paper viewports and mixed
// 2D/3D sets fall through to the shared batched path below.
#[cfg(not(target_arch = "wasm32"))]
let mut arena_served = false;
#[cfg(target_arch = "wasm32")]
let arena_served = false;
#[cfg(not(target_arch = "wasm32"))]
let _perf = crate::perf::enabled();
#[cfg(not(target_arch = "wasm32"))]
@@ -895,9 +892,12 @@ impl shader::Primitive for Primitive {
let ch = clip.height as f32;
let clip_right = clip.x + clip.width;
let clip_bottom = clip.y + clip.height;
for (i, vp) in self.viewports.iter().enumerate() {
let Some(inner) = pipeline.inners.get(self.base_slot + i) else {
break;
for vp in &self.viewports {
let Some(slot) = pipeline.slot_by_instance.get(&vp.instance_id) else {
continue;
};
let Some(inner) = pipeline.inners.get(*slot) else {
continue;
};
// Where the viewport would land on the surface in absolute
// pixels (i32 because either edge may stick off the canvas).
@@ -1433,14 +1433,9 @@ impl Scene {
sample.build_ms = nav_build_started.elapsed().as_secs_f64() * 1000.0;
sample
});
// Model panes permanently own slots 0..N. Paper starts after them so
// its sheet/content viewports never evict the Model slot's wire arena,
// textures, mesh batches and render cache during a layout-tab switch.
let base_slot = self.model_tiles.borrow().len();
Primitive {
viewports,
bg_color,
base_slot,
nav_perf: perf_nav,
}
}
@@ -1466,7 +1461,6 @@ impl Scene {
return Primitive {
viewports: vec![],
bg_color,
base_slot: tile_idx,
nav_perf: None,
};
};
@@ -1514,7 +1508,6 @@ impl Scene {
Primitive {
viewports,
bg_color,
base_slot: tile_idx,
nav_perf: perf_nav,
}
}
+27 -35
View File
@@ -1,7 +1,7 @@
//! Modal overlay shown while a CAD file is being loaded.
//!
//! Displays the file name, size, current phase, an indeterminate animated
//! progress bar, and a Cancel button.
//! Displays the file name, size, current phase, measured progress, and a
//! Cancel button.
use iced::time::Instant;
use iced::widget::{button, column, container, row, stack, text, Space};
@@ -10,20 +10,18 @@ use std::sync::atomic::Ordering;
use crate::app::{
Message, OpenProgress, OPEN_PHASE_CACHING, OPEN_PHASE_FINALIZING, OPEN_PHASE_PARSING,
OPEN_PHASE_READING,
OPEN_PHASE_READING, OPEN_PHASE_XREF,
};
const CARD_WIDTH: f32 = 420.0;
const BAR_TRACK_WIDTH: f32 = 380.0;
const BAR_TRACK_HEIGHT: f32 = 6.0;
const BAR_WINDOW_WIDTH: f32 = 100.0;
/// Period of one back-and-forth bounce, in milliseconds.
const BAR_PERIOD_MS: f32 = 1800.0;
fn phase_label(phase: u8) -> &'static str {
match phase {
OPEN_PHASE_READING => "Reading file…",
OPEN_PHASE_PARSING => "Parsing entities…",
OPEN_PHASE_XREF => "Loading references…",
OPEN_PHASE_CACHING => "Building scene caches…",
OPEN_PHASE_FINALIZING => "Finalizing…",
_ => "Working…",
@@ -46,29 +44,22 @@ fn format_size(bytes: u64) -> String {
}
}
/// Compute the left-offset of the moving highlight inside the track.
/// Bounces left↔right using a triangle wave so the user sees motion even when
/// the actual phase atomic stays put for a while.
fn bar_offset(elapsed_ms: f32) -> f32 {
let travel = (BAR_TRACK_WIDTH - BAR_WINDOW_WIDTH).max(0.0);
let cycle = (elapsed_ms / BAR_PERIOD_MS).fract();
let tri = if cycle < 0.5 {
cycle * 2.0
} else {
(1.0 - cycle) * 2.0
};
tri * travel
}
pub fn view<'a>(progress: &'a OpenProgress, _now: Instant) -> Element<'a, Message> {
let phase = progress.state.phase.load(Ordering::Acquire);
let basis_points = progress
.state
.basis_points
.load(Ordering::Relaxed)
.min(10000);
let fraction = basis_points as f32 / 10000.0;
let fill_width = BAR_TRACK_WIDTH * fraction;
let trailing = (BAR_TRACK_WIDTH - fill_width).max(0.0);
pub fn view<'a>(progress: &'a OpenProgress, now: Instant) -> Element<'a, Message> {
let phase = progress.phase.load(Ordering::Relaxed);
let elapsed_ms = now.saturating_duration_since(progress.started).as_millis() as f32;
// ── Animated indeterminate bar ────────────────────────────────────────
let offset = bar_offset(elapsed_ms);
let trailing = (BAR_TRACK_WIDTH - BAR_WINDOW_WIDTH - offset).max(0.0);
let bar_window: Element<'_, Message> = container(Space::new().width(Length::Fixed(BAR_WINDOW_WIDTH)).height(Length::Fixed(BAR_TRACK_HEIGHT)))
let bar_fill: Element<'_, Message> = container(
Space::new()
.width(Length::Fixed(fill_width))
.height(Length::Fixed(BAR_TRACK_HEIGHT)),
)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(Color {
r: 0.30,
@@ -84,11 +75,8 @@ pub fn view<'a>(progress: &'a OpenProgress, now: Instant) -> Element<'a, Message
})
.into();
let bar_moving: Element<'_, Message> = row![
Space::new()
.width(Length::Fixed(offset))
.height(Length::Fixed(BAR_TRACK_HEIGHT)),
bar_window,
let bar_value: Element<'_, Message> = row![
bar_fill,
Space::new()
.width(Length::Fixed(trailing))
.height(Length::Fixed(BAR_TRACK_HEIGHT)),
@@ -111,7 +99,7 @@ pub fn view<'a>(progress: &'a OpenProgress, now: Instant) -> Element<'a, Message
},
..Default::default()
}),
bar_moving,
bar_value,
]
.width(Length::Fixed(BAR_TRACK_WIDTH))
.height(Length::Fixed(BAR_TRACK_HEIGHT)),
@@ -136,7 +124,11 @@ pub fn view<'a>(progress: &'a OpenProgress, now: Instant) -> Element<'a, Message
a: 1.0,
});
let phase_line = text(phase_label(phase))
let phase_line = text(format!(
"{} {:.1}%",
phase_label(phase),
basis_points as f32 / 100.0
))
.size(12)
.color(Color {
r: 0.70,