perf: optimize large drawing workflows

This commit is contained in:
Hakan Seven 2026-07-25 02:58:46 +03:00
commit 71e0b88839
17 changed files with 1202 additions and 428 deletions

4
Cargo.lock generated
View file

@ -74,7 +74,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
[[package]] [[package]]
name = "acadrust" name = "acadrust"
version = "0.4.0" version = "0.4.0"
source = "git+https://github.com/OpenAEC-Foundation/acadifc?branch=main#3ef1e9c3483c44b93264c93965572c23b8678f17" source = "git+https://github.com/OpenAEC-Foundation/acadifc?rev=f045889e0243d88b6905124907487e0c0a1bf0e3#f045889e0243d88b6905124907487e0c0a1bf0e3"
dependencies = [ dependencies = [
"ahash 0.8.12", "ahash 0.8.12",
"anyhow", "anyhow",
@ -84,9 +84,11 @@ dependencies = [
"flate2", "flate2",
"indexmap", "indexmap",
"itoa", "itoa",
"memmap2",
"nalgebra", "nalgebra",
"nom 7.1.3", "nom 7.1.3",
"once_cell", "once_cell",
"rayon",
"ryu", "ryu",
"serde", "serde",
"thiserror 1.0.69", "thiserror 1.0.69",

View file

@ -86,14 +86,11 @@ lyon_tessellation = "1.0.20"
# (IApplicationAssociationRegistrationUI dialog). # (IApplicationAssociationRegistrationUI dialog).
# Win32_System_Registry — HKCU registration that lists the app under # Win32_System_Registry — HKCU registration that lists the app under
# "Open with" for the portable .exe. # "Open with" for the portable .exe.
windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_System_Com", "Win32_System_Registry", "Win32_Foundation"] } windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_System_Com", "Win32_System_Registry", "Win32_Storage_FileSystem", "Win32_Foundation"] }
[patch.crates-io] [patch.crates-io]
# The `acadrust` crate now tracks the OpenAEC-Foundation/acadifc fork's main # Track the DWG reader/writer performance work used by this release.
# (still the `acadrust` package): the in-flight DWG reader work plus the Arc acadrust = { git = "https://github.com/OpenAEC-Foundation/acadifc", rev = "f045889e0243d88b6905124907487e0c0a1bf0e3" }
# copy-on-write entity storage that makes the undo document clone cheap. Drop the
# patch once these land in a published 0.4 crate.
acadrust = { git = "https://github.com/OpenAEC-Foundation/acadifc", branch = "main" }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies] [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
# Native enables the plugin host runtime (out-of-process plugins). # Native enables the plugin host runtime (out-of-process plugins).

View file

@ -13,8 +13,11 @@ use iced;
use std::any::Any; use std::any::Any;
use std::collections::HashMap; use std::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc; use std::sync::Arc;
static NEXT_DOCUMENT_TAB_ID: AtomicU64 = AtomicU64::new(1);
// ── Dynamic input ────────────────────────────────────────────────────────── // ── Dynamic input ──────────────────────────────────────────────────────────
/// One quantity shown in the dynamic-input overlay near the cursor. /// One quantity shown in the dynamic-input overlay near the cursor.
@ -154,9 +157,15 @@ impl DocumentTab {
} }
pub(super) struct DocumentTab { pub(super) struct DocumentTab {
/// Stable identity across tab insert/remove operations. Background work
/// must never target a tab by its transient vector index.
pub(super) id: u64,
pub(super) scene: Scene, pub(super) scene: Scene,
pub(super) current_path: Option<PathBuf>, pub(super) current_path: Option<PathBuf>,
pub(super) dirty: bool, pub(super) dirty: bool,
/// Monotonic committed-edit/undo/redo revision. Background save completion
/// uses it with scene epochs so an older snapshot never clears newer work.
pub(super) edit_revision: u64,
pub(super) tab_title: String, pub(super) tab_title: String,
pub(super) properties: PropertiesPanel, pub(super) properties: PropertiesPanel,
pub(super) layers: LayerPanel, pub(super) layers: LayerPanel,
@ -413,9 +422,11 @@ impl DocumentTab {
} }
} }
Self { Self {
id: NEXT_DOCUMENT_TAB_ID.fetch_add(1, Ordering::Relaxed),
scene, scene,
current_path: None, current_path: None,
dirty: false, dirty: false,
edit_revision: 0,
prev_selection: Vec::new(), prev_selection: Vec::new(),
tab_title: format!("Drawing{}", n), tab_title: format!("Drawing{}", n),
properties: PropertiesPanel::empty(), properties: PropertiesPanel::empty(),

View file

@ -117,6 +117,7 @@ impl OpenCADStudio {
fn push_undo_entry(&mut self, i: usize, snapshot: HistorySnapshot) { fn push_undo_entry(&mut self, i: usize, snapshot: HistorySnapshot) {
self.tabs[i].history.undo_stack.push(snapshot); self.tabs[i].history.undo_stack.push(snapshot);
self.tabs[i].edit_revision = self.tabs[i].edit_revision.wrapping_add(1);
self.clear_redo_history(i); self.clear_redo_history(i);
self.trim_history(i); self.trim_history(i);
} }
@ -480,6 +481,7 @@ impl OpenCADStudio {
had_full: bool, had_full: bool,
changes: &[(Handle, crate::scene::ChangeKind)], changes: &[(Handle, crate::scene::ChangeKind)],
) { ) {
self.tabs[i].edit_revision = self.tabs[i].edit_revision.wrapping_add(1);
{ {
let scene = &mut self.tabs[i].scene; let scene = &mut self.tabs[i].scene;
if had_full { if had_full {

View file

@ -739,6 +739,10 @@ pub(super) struct OpenCADStudio {
// ── Unsaved-changes dialog ──────────────────────────────────────────── // ── Unsaved-changes dialog ────────────────────────────────────────────
/// Set when the user tries to close a tab or quit while there are unsaved changes. /// Set when the user tries to close a tab or quit while there are unsaved changes.
pending_close: Option<PendingClose>, pending_close: Option<PendingClose>,
/// Latest save job per stable tab id. Older completions may finish, but
/// cannot mark a newer document state clean or redirect its path.
active_save_jobs: std::collections::HashMap<u64, u64>,
save_job_serial: u64,
/// OS window for the unsaved-changes confirmation dialog. /// OS window for the unsaved-changes confirmation dialog.
// ── Custom Save-As dialog ───────────────────────────────────────────── // ── Custom Save-As dialog ─────────────────────────────────────────────
@ -847,6 +851,35 @@ pub(super) enum PendingClose {
Quit, Quit,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum SavePurpose {
Manual,
SaveAs,
Autosave,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum SaveContinuation {
None,
CloseTab,
Quit,
}
#[derive(Debug, Clone)]
pub struct SaveOutcome {
job_id: u64,
tab_id: u64,
epoch: u64,
revision: u64,
camera_generation: u64,
path: PathBuf,
previous_autosave: Option<PathBuf>,
set_current_path: bool,
purpose: SavePurpose,
continuation: SaveContinuation,
result: Result<(), String>,
}
/// Where a colour chosen in the standalone palette window should be applied. /// Where a colour chosen in the standalone palette window should be applied.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum ColorPickTarget { pub enum ColorPickTarget {
@ -1404,8 +1437,8 @@ pub enum Message {
AecDropBack, AecDropBack,
/// Periodic autosave tick — write `.sv$` recovery files for dirty tabs. /// Periodic autosave tick — write `.sv$` recovery files for dirty tabs.
AutoSave, AutoSave,
/// Save-as path picked for the unsaved-changes → save → close flow. /// Native background save/autosave completed.
UnsavedPickedSavePath(Option<std::path::PathBuf>), SaveFinished(SaveOutcome),
// ───────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────
CommandInput(String), CommandInput(String),
CommandSubmit, CommandSubmit,
@ -2418,6 +2451,8 @@ impl OpenCADStudio {
opening: None, opening: None,
pending_opens: std::collections::VecDeque::new(), pending_opens: std::collections::VecDeque::new(),
pending_close: None, pending_close: None,
active_save_jobs: std::collections::HashMap::new(),
save_job_serial: 0,
save_dialog_format: "DWG 2018".to_string(), save_dialog_format: "DWG 2018".to_string(),
save_dialog_filename: "drawing.dwg".to_string(), save_dialog_filename: "drawing.dwg".to_string(),
save_dialog_for_unsaved: false, save_dialog_for_unsaved: false,

View file

@ -234,130 +234,83 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven
} }
pub(super) fn on_unsaved_dialog_save(&mut self) -> Task<Message> { pub(super) fn on_unsaved_dialog_save(&mut self) -> Task<Message> {
match self.pending_close.take() { #[cfg(not(target_arch = "wasm32"))]
Some(crate::app::PendingClose::Tab(idx)) => { {
if let Some(path) = if cfg!(target_arch = "wasm32") { None } else { self.tabs[idx].current_path.clone() } { let Some(pending) = self.pending_close.clone() else {
match crate::io::save(&self.tabs[idx].scene.document, &path) { return Task::none();
Ok(()) => { };
self.command_line let (idx, continuation) = match pending {
.push_output(&format!("Saved: {}", path.display())); crate::app::PendingClose::Tab(idx) => {
self.tabs[idx].dirty = false; (idx, crate::app::SaveContinuation::CloseTab)
let close_win = self.close_unsaved_dialog_window();
let close_tab = self.update(Message::TabClose(idx));
return Task::batch(vec![close_win, close_tab]);
}
Err(e) => {
// Keep dialog open for retry.
self.command_line.push_error(&format!("Save failed: {e}"));
self.pending_close = Some(crate::app::PendingClose::Tab(idx));
}
}
} else {
// No path — close unsaved dialog and save a default
// DWG 2018 via the native destination dialog (no
// version picker; that is reserved for Save As).
self.pending_close = Some(crate::app::PendingClose::Tab(idx));
self.save_dialog_for_unsaved = true;
let close_win = self.close_unsaved_dialog_window();
let open_save = self.save_default_dwg2018(idx);
return Task::batch([close_win, open_save]);
}
}
Some(crate::app::PendingClose::Quit) => {
if let Some(idx) = self.tabs.iter().position(|t| t.dirty) {
if let Some(path) = if cfg!(target_arch = "wasm32") { None } else { self.tabs[idx].current_path.clone() } {
match crate::io::save(&self.tabs[idx].scene.document, &path) {
Ok(()) => {
self.command_line
.push_output(&format!("Saved: {}", path.display()));
self.tabs[idx].dirty = false;
}
Err(e) => {
self.command_line.push_error(&format!("Save failed: {e}"));
self.pending_close = Some(crate::app::PendingClose::Quit);
return Task::none();
}
}
} else {
// No path — close unsaved dialog and save a
// default DWG 2018 via the native destination
// dialog (version picker is Save-As only).
self.active_tab = idx;
self.pending_close = Some(crate::app::PendingClose::Quit);
self.save_dialog_for_unsaved = true;
let close_win = self.close_unsaved_dialog_window();
let open_save = self.save_default_dwg2018(idx);
return Task::batch([close_win, open_save]);
}
}
if self.tabs.iter().any(|t| t.dirty) {
// More dirty tabs — keep window open.
self.pending_close = Some(crate::app::PendingClose::Quit);
} else {
let close_win = self.close_unsaved_dialog_window();
return Task::batch(vec![close_win, self.exit_app()]);
}
}
None => {}
} }
Task::none() crate::app::PendingClose::Quit => {
let Some(idx) = self.tabs.iter().position(|tab| tab.dirty) else {
self.pending_close = None;
return Task::batch([
self.close_unsaved_dialog_window(),
self.exit_app(),
]);
};
(idx, crate::app::SaveContinuation::Quit)
}
};
if self.active_save_jobs.contains_key(&self.tabs[idx].id) {
self.command_line
.push_info("Save already running for this drawing.");
return Task::none();
}
if let Some(path) = self.tabs[idx].current_path.clone() {
let version = self.tabs[idx].scene.document.version;
self.prepare_native_save(idx);
let close = self.close_unsaved_dialog_window();
let save = self.queue_native_save(
idx,
path,
version,
crate::app::SavePurpose::Manual,
continuation,
false,
);
return Task::batch([close, save]);
}
self.active_tab = idx;
self.save_dialog_for_unsaved = true;
let close = self.close_unsaved_dialog_window();
let save = self.save_default_dwg2018(idx);
return Task::batch([close, save]);
}
#[cfg(target_arch = "wasm32")]
{
match self.pending_close.take() {
Some(crate::app::PendingClose::Tab(idx)) => {
self.pending_close = Some(crate::app::PendingClose::Tab(idx));
self.save_dialog_for_unsaved = true;
let close = self.close_unsaved_dialog_window();
let save = self.save_default_dwg2018(idx);
Task::batch([close, save])
}
Some(crate::app::PendingClose::Quit) => {
if let Some(idx) = self.tabs.iter().position(|tab| tab.dirty) {
self.active_tab = idx;
self.pending_close = Some(crate::app::PendingClose::Quit);
self.save_dialog_for_unsaved = true;
let close = self.close_unsaved_dialog_window();
let save = self.save_default_dwg2018(idx);
Task::batch([close, save])
} else {
Task::batch([
self.close_unsaved_dialog_window(),
self.exit_app(),
])
}
}
None => Task::none(),
}
}
} }
pub(super) fn on_unsaved_picked_save_path_some(&mut self, path: std::path::PathBuf) -> Task<Message> {
let (_ext, version) = crate::io::parse_save_format(&self.save_dialog_format);
match self.pending_close.take() {
Some(crate::app::PendingClose::Tab(idx)) => {
match crate::io::save_as_version(
&self.tabs[idx].scene.document,
&path,
version,
) {
Ok(()) => {
self.command_line
.push_output(&format!("Saved: {}", path.display()));
#[cfg(not(target_arch = "wasm32"))]
let _ = std::fs::remove_file(self.autosave_target(idx));
self.tabs[idx].current_path = Some(path);
self.tabs[idx].dirty = false;
return self.update(Message::TabClose(idx));
}
Err(e) => {
self.command_line.push_error(&format!("Save failed: {e}"));
self.pending_close = Some(crate::app::PendingClose::Tab(idx));
return self.open_unsaved_dialog_window();
}
}
}
Some(crate::app::PendingClose::Quit) => {
let i = self.active_tab;
match crate::io::save_as_version(
&self.tabs[i].scene.document,
&path,
version,
) {
Ok(()) => {
self.command_line
.push_output(&format!("Saved: {}", path.display()));
#[cfg(not(target_arch = "wasm32"))]
let _ = std::fs::remove_file(self.autosave_target(i));
self.tabs[i].current_path = Some(path);
self.tabs[i].dirty = false;
if self.tabs.iter().any(|t| t.dirty) {
self.pending_close = Some(crate::app::PendingClose::Quit);
return self.open_unsaved_dialog_window();
} else {
return self.exit_app();
}
}
Err(e) => {
self.command_line.push_error(&format!("Save failed: {e}"));
self.pending_close = Some(crate::app::PendingClose::Quit);
return self.open_unsaved_dialog_window();
}
}
}
None => {}
}
Task::none()
}
} }

View file

@ -768,6 +768,213 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
} }
} }
#[cfg(not(target_arch = "wasm32"))]
pub(in crate::app) fn prepare_native_save(&mut self, i: usize) {
self.sync_vport_display(i);
sync_annotation_scale_header(&mut self.tabs[i].scene);
self.stamp_header_sysvars(i);
self.tabs[i].scene.document.header.user_real1 =
self.tabs[i].scene.annotation_scale as f64;
self.sync_truck_solids_to_acis(i);
}
#[cfg(not(target_arch = "wasm32"))]
pub(in crate::app) fn queue_native_save(
&mut self,
i: usize,
path: std::path::PathBuf,
version: acadrust::DxfVersion,
purpose: crate::app::SavePurpose,
continuation: crate::app::SaveContinuation,
set_current_path: bool,
) -> Task<Message> {
let tab_id = self.tabs[i].id;
if self.active_save_jobs.contains_key(&tab_id) {
if purpose != crate::app::SavePurpose::Autosave {
self.command_line
.push_info("Save already running for this drawing.");
}
return Task::none();
}
let epoch = self.tabs[i].scene.geometry_epoch;
let revision = self.tabs[i].edit_revision;
let camera_generation = self.tabs[i].scene.camera_generation;
let thumbnail = if purpose == crate::app::SavePurpose::Autosave {
None
} else {
let scene = &self.tabs[i].scene;
Some((
scene.entity_wires(),
scene.camera.borrow().clone(),
scene.bg_color,
version >= acadrust::DxfVersion::AC1027,
self.vp_size,
))
};
let clone_started = std::time::Instant::now();
let mut snapshot = self.tabs[i].scene.document.clone();
let clone_ms = clone_started.elapsed().as_secs_f64() * 1000.0;
if std::env::var_os("OCS_PERF").is_some() {
eprintln!(
"[perf] save-snapshot {:.1}ms entities={} objects={} purpose={purpose:?}",
clone_ms,
snapshot.entities().count(),
snapshot.objects.len(),
);
}
self.save_job_serial = self.save_job_serial.wrapping_add(1);
let job_id = self.save_job_serial;
self.active_save_jobs.insert(tab_id, job_id);
let previous_autosave = set_current_path.then(|| self.autosave_target(i));
let backup = purpose != crate::app::SavePurpose::Autosave && self.backup_on_save;
let worker_path = path.clone();
Task::perform(
async move {
let result = std::thread::spawn(move || {
if let Some((wires, camera, bg_color, png, viewport)) = thumbnail {
let started = std::time::Instant::now();
snapshot.preview = crate::io::thumbnail::from_snapshot(
&wires,
&camera,
bg_color,
png,
viewport,
);
if std::env::var_os("OCS_PERF").is_some() {
eprintln!(
"[perf] save-thumbnail {:.1}ms wires={}",
started.elapsed().as_secs_f64() * 1000.0,
wires.len(),
);
}
}
crate::io::save_owned_as_version_atomic(
snapshot,
&worker_path,
version,
backup,
)
})
.join()
.unwrap_or_else(|_| Err("save worker panicked".to_string()));
crate::app::SaveOutcome {
job_id,
tab_id,
epoch,
revision,
camera_generation,
path,
previous_autosave,
set_current_path,
purpose,
continuation,
result,
}
},
Message::SaveFinished,
)
}
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn on_save_finished(
&mut self,
outcome: crate::app::SaveOutcome,
) -> Task<Message> {
let latest = self.active_save_jobs.get(&outcome.tab_id).copied()
== Some(outcome.job_id);
if latest {
self.active_save_jobs.remove(&outcome.tab_id);
}
let Some(i) = self.tabs.iter().position(|tab| tab.id == outcome.tab_id) else {
if outcome.purpose == crate::app::SavePurpose::Autosave {
let _ = std::fs::remove_file(&outcome.path);
}
return Task::none();
};
if !latest {
if outcome.purpose == crate::app::SavePurpose::Autosave {
let _ = std::fs::remove_file(&outcome.path);
}
return Task::none();
}
if let Err(error) = outcome.result {
self.command_line
.push_error(&format!("Save failed: {error}"));
return match outcome.continuation {
crate::app::SaveContinuation::CloseTab => {
self.pending_close = Some(crate::app::PendingClose::Tab(i));
self.open_unsaved_dialog_window()
}
crate::app::SaveContinuation::Quit => {
self.pending_close = Some(crate::app::PendingClose::Quit);
self.open_unsaved_dialog_window()
}
crate::app::SaveContinuation::None => Task::none(),
};
}
let snapshot_is_current = self.tabs[i].scene.geometry_epoch == outcome.epoch
&& self.tabs[i].edit_revision == outcome.revision
&& self.tabs[i].scene.camera_generation == outcome.camera_generation;
let mut tasks = Vec::new();
match outcome.purpose {
crate::app::SavePurpose::Autosave => {
self.command_line.push_output("Autosaved 1 drawing");
}
crate::app::SavePurpose::Manual | crate::app::SavePurpose::SaveAs => {
self.command_line
.push_output(&format!("Saved: {}", outcome.path.display()));
self.recent_thumbs.remove(&outcome.path);
if let Some(previous) = outcome.previous_autosave {
if previous != outcome.path {
let _ = std::fs::remove_file(previous);
}
}
if outcome.set_current_path {
self.tabs[i].current_path = Some(outcome.path.clone());
tasks.push(self.push_recent(outcome.path.clone()));
}
if snapshot_is_current {
self.tabs[i].dirty = false;
let _ = std::fs::remove_file(outcome.path.with_extension("sv$"));
}
}
}
match outcome.continuation {
crate::app::SaveContinuation::None => {}
crate::app::SaveContinuation::CloseTab if snapshot_is_current => {
self.pending_close = None;
tasks.push(self.close_unsaved_dialog_window());
tasks.push(self.update(Message::TabClose(i)));
}
crate::app::SaveContinuation::Quit if snapshot_is_current => {
self.pending_close = None;
if self.tabs.iter().any(|tab| tab.dirty) {
self.pending_close = Some(crate::app::PendingClose::Quit);
tasks.push(self.open_unsaved_dialog_window());
} else {
tasks.push(self.close_unsaved_dialog_window());
tasks.push(self.exit_app());
}
}
crate::app::SaveContinuation::CloseTab => {
self.pending_close = Some(crate::app::PendingClose::Tab(i));
tasks.push(self.open_unsaved_dialog_window());
}
crate::app::SaveContinuation::Quit => {
self.pending_close = Some(crate::app::PendingClose::Quit);
tasks.push(self.open_unsaved_dialog_window());
}
}
Task::batch(tasks)
}
pub(super) fn on_save_file(&mut self) -> Task<Message> { pub(super) fn on_save_file(&mut self) -> Task<Message> {
if self.read_only { if self.read_only {
self.command_line self.command_line
@ -775,37 +982,28 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
return Task::none(); return Task::none();
} }
let i = self.active_tab; let i = self.active_tab;
// Stamp the live grid/snap toggles onto the VPort so the file // Web serializes immediately below. Native preparation happens
// reflects them even if they came from settings with no // once after the destination/version is known.
// in-session toggle (#121). #[cfg(target_arch = "wasm32")]
self.sync_vport_display(i); {
// Persist Ortho ($ORTHOMODE) + running OSNAP ($OSMODE) into the self.sync_vport_display(i);
// drawing header so they survive save/reopen (per-drawing). self.stamp_header_sysvars(i);
self.stamp_header_sysvars(i); }
// Native: save straight to the known path. Web has no path // Native: save straight to the known path. Web has no path
// (downloads instead), so always go through the Save dialog. // (downloads instead), so always go through the Save dialog.
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
if let Some(path) = self.tabs[i].current_path.clone() { if let Some(path) = self.tabs[i].current_path.clone() {
self.tabs[i].scene.document.header.user_real1 =
self.tabs[i].scene.annotation_scale as f64;
// A direct Save preserves the document's current version. // A direct Save preserves the document's current version.
if self.backup_on_save {
crate::io::write_backup(&path);
}
self.sync_truck_solids_to_acis(i);
let ver = self.tabs[i].scene.document.version; let ver = self.tabs[i].scene.document.version;
self.stamp_thumbnail(i, ver); self.prepare_native_save(i);
match crate::io::save(&self.tabs[i].scene.document, &path) { return self.queue_native_save(
Ok(()) => { i,
self.command_line path,
.push_output(&format!("Saved: {}", path.display())); ver,
self.tabs[i].dirty = false; crate::app::SavePurpose::Manual,
// A clean save supersedes any autosave recovery copy. crate::app::SaveContinuation::None,
let _ = std::fs::remove_file(path.with_extension("sv$")); false,
} );
Err(e) => self.command_line.push_error(&format!("Save failed: {e}")),
}
return Task::none();
} }
self.save_dialog_for_unsaved = false; self.save_dialog_for_unsaved = false;
self.save_default_dwg2018(i) self.save_default_dwg2018(i)
@ -937,37 +1135,24 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
}; };
let (_ext, version) = crate::io::parse_save_format(&self.save_dialog_format); let (_ext, version) = crate::io::parse_save_format(&self.save_dialog_format);
let i = self.active_tab; let i = self.active_tab;
sync_annotation_scale_header(&mut self.tabs[i].scene); self.prepare_native_save(i);
// Persist Ortho / running OSNAP into the header (Save-As path). let continuation = if self.save_dialog_for_unsaved {
self.stamp_header_sysvars(i); match self.pending_close {
self.sync_truck_solids_to_acis(i); Some(crate::app::PendingClose::Tab(_)) => crate::app::SaveContinuation::CloseTab,
self.stamp_thumbnail(i, version); Some(crate::app::PendingClose::Quit) => crate::app::SaveContinuation::Quit,
if self.backup_on_save { None => crate::app::SaveContinuation::None,
crate::io::write_backup(&path);
}
match crate::io::save_as_version(&self.tabs[i].scene.document, &path, version) {
Ok(()) => {
self.command_line
.push_output(&format!("Saved: {}", path.display()));
// Drop any prior autosave copy — including the temp one used
// while the drawing was still unsaved — before the tab takes on
// its new path.
let _ = std::fs::remove_file(self.autosave_target(i));
self.tabs[i].current_path = Some(path.clone());
self.tabs[i].dirty = false;
let _ = std::fs::remove_file(path.with_extension("sv$"));
let recent = self.push_recent(path.clone());
if self.save_dialog_for_unsaved {
return Task::batch([
recent,
self.update(Message::UnsavedPickedSavePath(Some(path))),
]);
}
return recent;
} }
Err(e) => self.command_line.push_error(&format!("Save failed: {e}")), } else {
} crate::app::SaveContinuation::None
Task::none() };
self.queue_native_save(
i,
path,
version,
crate::app::SavePurpose::SaveAs,
continuation,
true,
)
} }
/// AEC-drop warning → "Save anyway": accept the loss and proceed with the /// AEC-drop warning → "Save anyway": accept the loss and proceed with the
@ -1019,25 +1204,25 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
/// touches the original file or the dirty flag. /// touches the original file or the dirty flag.
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub(super) fn on_autosave(&mut self) -> Task<Message> { pub(super) fn on_autosave(&mut self) -> Task<Message> {
let mut n = 0; let mut tasks = Vec::new();
for i in 0..self.tabs.len() { for i in 0..self.tabs.len() {
if !self.tabs[i].dirty { if !self.tabs[i].dirty
|| self.active_save_jobs.contains_key(&self.tabs[i].id)
{
continue; continue;
} }
let version = self.tabs[i].scene.document.version; let version = self.tabs[i].scene.document.version;
if let Ok(bytes) = let target = self.autosave_target(i);
crate::io::save_to_bytes(&self.tabs[i].scene.document, "dwg", version) tasks.push(self.queue_native_save(
{ i,
if std::fs::write(self.autosave_target(i), bytes).is_ok() { target,
n += 1; version,
} crate::app::SavePurpose::Autosave,
} crate::app::SaveContinuation::None,
false,
));
} }
if n > 0 { Task::batch(tasks)
self.command_line
.push_output(&format!("Autosaved {n} drawing(s)"));
}
Task::none()
} }
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]

View file

@ -3770,17 +3770,11 @@ impl OpenCADStudio {
Message::AutoSave => self.on_autosave(), Message::AutoSave => self.on_autosave(),
Message::UnsavedPickedSavePath(Some(path)) => { #[cfg(not(target_arch = "wasm32"))]
self.on_unsaved_picked_save_path_some(path) Message::SaveFinished(outcome) => self.on_save_finished(outcome),
}
Message::UnsavedPickedSavePath(None) => { #[cfg(target_arch = "wasm32")]
// User cancelled the save-as dialog — re-open the confirmation dialog. Message::SaveFinished(_) => Task::none(),
if self.pending_close.is_some() {
return self.open_unsaved_dialog_window();
}
Task::none()
}
// ── Page Setup ──────────────────────────────────────────────── // ── Page Setup ────────────────────────────────────────────────
Message::UpdateCheckResult(latest) => { Message::UpdateCheckResult(latest) => {

View file

@ -183,6 +183,12 @@ pub fn load_file(path: &Path) -> Result<CadDocument, String> {
match effective.as_str() { match effective.as_str() {
"dwg" => { "dwg" => {
#[cfg(not(target_arch = "wasm32"))]
let mut doc = DwgReader::from_mmap(path)
.map_err(|e| e.to_string())?
.read()
.map_err(|e| e.to_string())?;
#[cfg(target_arch = "wasm32")]
let mut doc = DwgReader::from_file(path) let mut doc = DwgReader::from_file(path)
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
.read() .read()
@ -418,19 +424,130 @@ pub fn save_as_version(
path: &Path, path: &Path,
version: acadrust::DxfVersion, version: acadrust::DxfVersion,
) -> Result<(), String> { ) -> Result<(), String> {
let mut doc = doc.clone(); let clone_started = std::time::Instant::now();
let snapshot = doc.clone();
let clone_ms = clone_started.elapsed().as_secs_f64() * 1000.0;
save_owned_as_version_inner(snapshot, path, version, false, clone_ms)
}
/// Save an owned document snapshot. Preparation, serialization, compression and
/// disk I/O can therefore run on a worker without borrowing live editor state.
/// Output is written beside the destination and atomically renamed only after a
/// complete file exists, so a failed save cannot truncate the previous drawing.
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
pub fn save_owned_as_version_atomic(
doc: CadDocument,
path: &Path,
version: acadrust::DxfVersion,
backup: bool,
) -> Result<(), String> {
save_owned_as_version_inner(doc, path, version, backup, 0.0)
}
fn save_owned_as_version_inner(
mut doc: CadDocument,
path: &Path,
version: acadrust::DxfVersion,
backup: bool,
clone_ms: f64,
) -> Result<(), String> {
let perf = std::env::var_os("OCS_PERF").is_some();
let total_started = std::time::Instant::now();
doc.version = version; doc.version = version;
let styles_started = std::time::Instant::now();
sync_current_styles_on_save(&mut doc); sync_current_styles_on_save(&mut doc);
let styles_ms = styles_started.elapsed().as_secs_f64() * 1000.0;
let dimensions_started = std::time::Instant::now();
crate::modules::draw::modify::explode::bake_dimension_blocks(&mut doc); crate::modules::draw::modify::explode::bake_dimension_blocks(&mut doc);
let ext = path let dimensions_ms = dimensions_started.elapsed().as_secs_f64() * 1000.0;
let temp_path = save_temp_path(path);
let ext = temp_path
.extension() .extension()
.map(|e| e.to_string_lossy().to_lowercase()) .map(|e| e.to_string_lossy().to_lowercase())
.unwrap_or_default(); .unwrap_or_default();
match ext.as_str() { let write_started = std::time::Instant::now();
let result = match ext.as_str() {
"dxf" => DxfWriter::new(&doc) "dxf" => DxfWriter::new(&doc)
.write_to_file(path) .write_to_file(&temp_path)
.map_err(|e| e.to_string()), .map_err(|e| e.to_string()),
_ => DwgWriter::write_to_file(path, &doc).map_err(|e| e.to_string()), _ => DwgWriter::write_to_file(&temp_path, &doc).map_err(|e| e.to_string()),
};
if let Err(error) = result {
let _ = std::fs::remove_file(&temp_path);
return Err(error);
}
if backup {
write_backup(path);
}
if let Err(error) = replace_save_file(&temp_path, path) {
let _ = std::fs::remove_file(&temp_path);
return Err(format!("replace {}: {error}", path.display()));
}
if perf {
eprintln!(
"[perf] save total={:.1}ms clone={:.1} styles={:.1} dimensions={:.1} write={:.1} entities={} objects={} path={}",
total_started.elapsed().as_secs_f64() * 1000.0,
clone_ms,
styles_ms,
dimensions_ms,
write_started.elapsed().as_secs_f64() * 1000.0,
doc.entities().count(),
doc.objects.len(),
path.display(),
);
}
Ok(())
}
fn save_temp_path(path: &Path) -> PathBuf {
static SERIAL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
let serial = SERIAL.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let stem = path
.file_stem()
.map(|value| value.to_string_lossy().into_owned())
.unwrap_or_else(|| "drawing".to_string());
let extension = path
.extension()
.map(|value| value.to_string_lossy().into_owned())
.unwrap_or_else(|| "dwg".to_string());
let name = format!(
".{stem}.ocs-save-{}-{serial}.{extension}",
std::process::id()
);
path.parent().unwrap_or_else(|| Path::new(".")).join(name)
}
#[cfg(not(target_os = "windows"))]
fn replace_save_file(temp_path: &Path, path: &Path) -> std::io::Result<()> {
std::fs::rename(temp_path, path)
}
#[cfg(target_os = "windows")]
fn replace_save_file(temp_path: &Path, path: &Path) -> std::io::Result<()> {
if !path.exists() {
return std::fs::rename(temp_path, path);
}
use std::os::windows::ffi::OsStrExt;
let replaced: Vec<u16> = path.as_os_str().encode_wide().chain(Some(0)).collect();
let replacement: Vec<u16> = temp_path
.as_os_str()
.encode_wide()
.chain(Some(0))
.collect();
let ok = unsafe {
windows_sys::Win32::Storage::FileSystem::ReplaceFileW(
replaced.as_ptr(),
replacement.as_ptr(),
std::ptr::null(),
0,
std::ptr::null(),
std::ptr::null(),
)
};
if ok == 0 {
Err(std::io::Error::last_os_error())
} else {
Ok(())
} }
} }
@ -450,18 +567,42 @@ pub fn save_to_bytes(
ext: &str, ext: &str,
version: acadrust::DxfVersion, version: acadrust::DxfVersion,
) -> Result<Vec<u8>, String> { ) -> Result<Vec<u8>, String> {
let perf = std::env::var_os("OCS_PERF").is_some();
let total_started = std::time::Instant::now();
let clone_started = std::time::Instant::now();
let mut doc = doc.clone(); let mut doc = doc.clone();
let clone_ms = clone_started.elapsed().as_secs_f64() * 1000.0;
doc.version = version; doc.version = version;
let styles_started = std::time::Instant::now();
sync_current_styles_on_save(&mut doc); sync_current_styles_on_save(&mut doc);
let styles_ms = styles_started.elapsed().as_secs_f64() * 1000.0;
let dimensions_started = std::time::Instant::now();
crate::modules::draw::modify::explode::bake_dimension_blocks(&mut doc); crate::modules::draw::modify::explode::bake_dimension_blocks(&mut doc);
match ext.to_lowercase().as_str() { let dimensions_ms = dimensions_started.elapsed().as_secs_f64() * 1000.0;
let write_started = std::time::Instant::now();
let result = match ext.to_lowercase().as_str() {
"dxf" => DxfWriter::new(&doc).write_to_vec().map_err(|e| e.to_string()), "dxf" => DxfWriter::new(&doc).write_to_vec().map_err(|e| e.to_string()),
_ => { _ => {
let mut buf = std::io::Cursor::new(Vec::new()); let mut buf = std::io::Cursor::new(Vec::new());
DwgWriter::write_to_writer(&mut buf, &doc).map_err(|e| e.to_string())?; DwgWriter::write_to_writer(&mut buf, &doc).map_err(|e| e.to_string())?;
Ok(buf.into_inner()) Ok(buf.into_inner())
} }
};
if perf {
let bytes = result.as_ref().map_or(0, Vec::len);
eprintln!(
"[perf] save-bytes total={:.1}ms clone={:.1} styles={:.1} dimensions={:.1} write={:.1} bytes={} entities={} objects={}",
total_started.elapsed().as_secs_f64() * 1000.0,
clone_ms,
styles_ms,
dimensions_ms,
write_started.elapsed().as_secs_f64() * 1000.0,
bytes,
doc.entities().count(),
doc.objects.len(),
);
} }
result
} }

View file

@ -15,6 +15,7 @@ use image::{ImageFormat, Rgb, RgbImage};
use std::io::Cursor; use std::io::Cursor;
use crate::scene::{Scene, WireModel}; use crate::scene::{Scene, WireModel};
use crate::scene::view::camera::Camera;
/// Longest edge of the generated thumbnail, in pixels. /// Longest edge of the generated thumbnail, in pixels.
const MAX_DIM: u32 = 256; const MAX_DIM: u32 = 256;
@ -31,6 +32,20 @@ const MAX_DIM: u32 = 256;
/// (AC1027) on, so the caller passes `false` for older targets → BMP/DIB. /// (AC1027) on, so the caller passes `false` for older targets → BMP/DIB.
pub fn from_scene(scene: &Scene, png: bool, viewport: (f32, f32)) -> Option<Preview> { pub fn from_scene(scene: &Scene, png: bool, viewport: (f32, f32)) -> Option<Preview> {
let wires = scene.entity_wires(); let wires = scene.entity_wires();
let camera = scene.camera.borrow();
from_snapshot(&wires, &camera, scene.bg_color, png, viewport)
}
/// Build a preview from immutable render inputs. Native background saves retain
/// the resident wire `Arc` and a small camera copy, then run the point scan and
/// image encoding on the save worker instead of blocking the UI thread.
pub fn from_snapshot(
wires: &[WireModel],
camera: &Camera,
bg_color: [f32; 4],
png: bool,
viewport: (f32, f32),
) -> Option<Preview> {
if wires.is_empty() { if wires.is_empty() {
return None; return None;
} }
@ -49,9 +64,8 @@ pub fn from_scene(scene: &Scene, png: bool, viewport: (f32, f32)) -> Option<Prev
width: cw as f32, width: cw as f32,
height: ch as f32, height: ch as f32,
}; };
let cam = scene.camera.borrow(); rasterize(wires, cw, ch, bg_color, png, |x, y, z| {
rasterize(&wires, cw, ch, scene.bg_color, png, |x, y, z| { camera.project(glam::DVec3::new(x, y, z), bounds)
cam.project(glam::DVec3::new(x, y, z), bounds)
.map(|s| (s.x.round() as i32, s.y.round() as i32)) .map(|s| (s.x.round() as i32, s.y.round() as i32))
}) })
} }

View file

@ -944,34 +944,59 @@ fn explode_dimension(dim: &Dimension, doc: &CadDocument) -> Vec<EntityType> {
/// field but not `base`, so without this the saved group 10 goes stale and the /// field but not `base`, so without this the saved group 10 goes stale and the
/// dimension's line / leader / origin jumps on reload (#181). Angular-2-line /// dimension's line / leader / origin jumps on reload (#181). Angular-2-line
/// keeps a distinct base point (the second line's point) and is left alone. /// keeps a distinct base point (the second line's point) and is left alone.
fn sync_dimension_base_points(doc: &mut CadDocument) { fn sync_dimension_base_points_and_collect_pending(doc: &mut CadDocument) -> Vec<Handle> {
for e in doc.entities_mut() { let existing_blocks: rustc_hash::FxHashSet<String> = doc
if let EntityType::Dimension(d) = e { .block_records
let def = match d { .iter()
Dimension::Linear(x) => Some(x.definition_point), .map(|record| record.name.to_ascii_lowercase())
Dimension::Aligned(x) => Some(x.definition_point), .collect();
Dimension::Radius(x) => Some(x.definition_point), let dimensions: Vec<(Handle, Option<Vector3>, bool)> = doc
Dimension::Diameter(x) => Some(x.definition_point), .entities()
Dimension::Ordinate(x) => Some(x.definition_point), .filter_map(|entity| {
Dimension::Angular3Pt(x) => Some(x.definition_point), let EntityType::Dimension(dimension) = entity else {
return None;
};
let definition_point = match dimension {
Dimension::Linear(value) => Some(value.definition_point),
Dimension::Aligned(value) => Some(value.definition_point),
Dimension::Radius(value) => Some(value.definition_point),
Dimension::Diameter(value) => Some(value.definition_point),
Dimension::Ordinate(value) => Some(value.definition_point),
Dimension::Angular3Pt(value) => Some(value.definition_point),
Dimension::Angular2Ln(_) => None, Dimension::Angular2Ln(_) => None,
}; };
if let Some(p) = def { let base = dimension.base();
d.base_mut().definition_point = p; let needs_sync =
definition_point.filter(|point| *point != base.definition_point);
let block_missing = base.block_name.trim().is_empty()
|| !existing_blocks.contains(&base.block_name.to_ascii_lowercase());
Some((base.common.handle, needs_sync, block_missing))
})
.collect();
let mut pending = Vec::new();
for (handle, definition_point, block_missing) in dimensions {
if let Some(point) = definition_point {
if let Some(EntityType::Dimension(dimension)) = doc.get_entity_mut(handle) {
dimension.base_mut().definition_point = point;
} }
} }
if block_missing {
pending.push(handle);
}
} }
pending
} }
/// Smallest free `*D<n>` anonymous block name in `doc`. /// Smallest free `*D<n>` anonymous block name in `doc`.
fn next_dimension_block_name(doc: &CadDocument) -> String { fn next_dimension_block_name(doc: &CadDocument, next: &mut u64) -> String {
let mut n = 0u64;
loop { loop {
let n = *next;
*next = (*next).saturating_add(1);
let cand = format!("*D{n}"); let cand = format!("*D{n}");
if doc.block_records.get(&cand).is_none() { if doc.block_records.get(&cand).is_none() {
return cand; return cand;
} }
n += 1;
} }
} }
@ -993,37 +1018,32 @@ fn next_dimension_block_name(doc: &CadDocument) -> String {
/// DWG, or copied via the `*D`-cloning copy path) are left untouched so their /// DWG, or copied via the `*D`-cloning copy path) are left untouched so their
/// original graphics are preserved. /// original graphics are preserved.
pub fn bake_dimension_blocks(doc: &mut CadDocument) { pub fn bake_dimension_blocks(doc: &mut CadDocument) {
// Keep group-10 (base.definition_point) in step with the per-type geometry // Keep group-10 in step and find missing blocks in one entity pass. Existing
// before writing — see sync_dimension_base_points. // `*D` blocks are the save cache: only invalidated/new dimensions enter the
sync_dimension_base_points(doc); // relatively expensive geometry generation below.
let pending = sync_dimension_base_points_and_collect_pending(doc);
// Handles of dimensions whose block reference is missing or dangling. let dimensions: Vec<(Handle, Dimension)> = pending
let pending: Vec<Handle> = doc .into_iter()
.entities() .filter_map(|handle| match doc.get_entity(handle) {
.filter_map(|e| match e { Some(EntityType::Dimension(d)) => Some((handle, d.clone())),
EntityType::Dimension(d) => {
let bn = &d.base().block_name;
if bn.trim().is_empty() || doc.block_records.get(bn).is_none() {
Some(d.base().common.handle)
} else {
None
}
}
_ => None, _ => None,
}) })
.collect(); .collect();
for handle in pending { use crate::par::prelude::*;
let dim = match doc.get_entity(handle) { let doc_ref: &CadDocument = doc;
Some(EntityType::Dimension(d)) => d.clone(), let generated: Vec<(Handle, Vec<EntityType>)> = dimensions
_ => continue, .par_iter()
}; .map(|(handle, dim)| (*handle, explode_dimension(dim, doc_ref)))
let subs = explode_dimension(&dim, doc); .collect();
let mut next_block_number = 0u64;
for (handle, subs) in generated {
if subs.is_empty() { if subs.is_empty() {
continue; continue;
} }
let name = next_dimension_block_name(doc); let name = next_dimension_block_name(doc, &mut next_block_number);
// Reserve three consecutive handles for the record / block / endblk. // Reserve three consecutive handles for the record / block / endblk.
// Adding the block + endblk (which carry explicit handles) advances the // Adding the block + endblk (which carry explicit handles) advances the
// document's handle counter past them, so the NULL-handle sub-entities // document's handle counter past them, so the NULL-handle sub-entities

View file

@ -479,6 +479,128 @@ pub struct InteractionHandleIndex {
handles: SpatialSet<u64>, handles: SpatialSet<u64>,
} }
struct WireIndexEntries {
wire: Option<Entry3<u32>>,
segments: Vec<Entry3<SegmentRef>>,
snap_points: Vec<Entry3<SnapPointRef>>,
key_vertices: Vec<Entry3<KeyVertexRef>>,
key_segments: Vec<Entry3<KeySegmentRef>>,
fill_triangles: Vec<Entry3<TriangleRef>>,
pick_triangles: Vec<Entry3<TriangleRef>>,
glyphs: Vec<Entry3<GlyphRef>>,
unbounded: bool,
max_line_half_width_px: f32,
}
fn collect_wire_index_entries(wire_idx: u32, wire: &WireModel) -> WireIndexEntries {
let mut entries = WireIndexEntries {
wire: finite_wire_aabb3(wire).map(|aabb| Entry3 {
aabb,
value: wire_idx,
}),
segments: Vec::with_capacity(wire.points.len().saturating_sub(1)),
snap_points: Vec::with_capacity(wire.snap_pts.len()),
key_vertices: Vec::with_capacity(wire.key_vertices.len()),
key_segments: Vec::with_capacity(wire.key_vertices.len().saturating_sub(1)),
fill_triangles: Vec::with_capacity(wire.fill_tris.len() / 3),
pick_triangles: Vec::with_capacity(wire.pick_tris.len() / 3),
glyphs: Vec::with_capacity(wire.text_verts.len() / 6),
unbounded: false,
max_line_half_width_px: if wire.line_weight_px.is_finite() {
(wire.line_weight_px * 0.5).max(0.0)
} else {
0.0
},
};
entries.unbounded = entries.wire.is_none();
for start in 0..wire.points.len().saturating_sub(1) {
let Some(aabb) = points_aabb3([
wire_point(wire, start),
wire_point(wire, start + 1),
]) else {
continue;
};
entries.segments.push(Entry3 {
aabb,
value: SegmentRef {
wire: wire_idx,
start: start as u32,
},
});
}
for (index, (point, _)) in wire.snap_pts.iter().enumerate() {
if point.is_finite() {
entries.snap_points.push(Entry3 {
aabb: [point.x, point.y, point.z, point.x, point.y, point.z],
value: SnapPointRef {
wire: wire_idx,
index: index as u32,
},
});
}
}
for (index, &point) in wire.key_vertices.iter().enumerate() {
if point.iter().all(|value| value.is_finite()) {
entries.key_vertices.push(Entry3 {
aabb: [point[0], point[1], point[2], point[0], point[1], point[2]],
value: KeyVertexRef {
wire: wire_idx,
index: index as u32,
},
});
}
}
for start in 0..wire.key_vertices.len().saturating_sub(1) {
let Some(aabb) =
points_aabb3([wire.key_vertices[start], wire.key_vertices[start + 1]])
else {
continue;
};
entries.key_segments.push(Entry3 {
aabb,
value: KeySegmentRef {
wire: wire_idx,
start: start as u32,
},
});
}
append_triangle_entries(
wire_idx,
&wire.fill_tris,
&wire.fill_tris_low,
&mut entries.fill_triangles,
);
append_triangle_entries(
wire_idx,
&wire.pick_tris,
&wire.pick_tris_low,
&mut entries.pick_triangles,
);
for start in (0..wire.text_verts.len()).step_by(6) {
let Some(quad) = wire.text_verts.get(start..start + 6) else {
break;
};
let Some(aabb) = points_aabb3(quad.iter().map(|vertex| {
[
vertex.pos[0] as f64 + vertex.pos_low[0] as f64,
vertex.pos[1] as f64 + vertex.pos_low[1] as f64,
vertex.pos[2] as f64 + vertex.pos_low[2] as f64,
]
})) else {
continue;
};
entries.glyphs.push(Entry3 {
aabb,
value: GlyphRef {
wire: wire_idx,
start: start as u32,
},
});
}
entries
}
impl InteractionHandleIndex { impl InteractionHandleIndex {
pub fn build(entries: impl IntoIterator<Item = (u64, [f64; 6])>) -> Self { pub fn build(entries: impl IntoIterator<Item = (u64, [f64; 6])>) -> Self {
Self { Self {
@ -554,106 +676,76 @@ impl InteractionIndex {
let mut unbounded_wires = Vec::new(); let mut unbounded_wires = Vec::new();
let mut max_line_half_width_px = 0.0f32; let mut max_line_half_width_px = 0.0f32;
for (wire_idx, wire) in wires.iter().enumerate() { #[cfg(not(target_arch = "wasm32"))]
if wire.line_weight_px.is_finite() { let per_wire: Vec<WireIndexEntries> = {
max_line_half_width_px = use crate::par::prelude::*;
max_line_half_width_px.max((wire.line_weight_px * 0.5).max(0.0)); wires
} .par_iter()
let wire_idx = wire_idx as u32; .enumerate()
if let Some(aabb) = finite_wire_aabb3(wire) { .map(|(index, wire)| collect_wire_index_entries(index as u32, wire))
wire_entries.push(Entry3 { .collect()
aabb, };
value: wire_idx, #[cfg(target_arch = "wasm32")]
}); let per_wire: Vec<WireIndexEntries> = wires
} else { .iter()
unbounded_wires.push(wire_idx); .enumerate()
} .map(|(index, wire)| collect_wire_index_entries(index as u32, wire))
.collect();
for start in 0..wire.points.len().saturating_sub(1) { // Every per-wire worker knows its exact output sizes. Reserve the flat
let a = wire_point(wire, start); // arrays once before draining them so a dense block drawing does not
let b = wire_point(wire, start + 1); // repeatedly copy already-flattened entries while the Vecs grow.
let Some(aabb) = points_aabb3([a, b]) else { wire_entries.reserve(per_wire.iter().filter(|entries| entries.wire.is_some()).count());
continue; unbounded_wires.reserve(per_wire.iter().filter(|entries| entries.unbounded).count());
}; segment_entries.reserve(per_wire.iter().map(|entries| entries.segments.len()).sum());
segment_entries.push(Entry3 { snap_point_entries.reserve(
aabb, per_wire
value: SegmentRef { .iter()
wire: wire_idx, .map(|entries| entries.snap_points.len())
start: start as u32, .sum(),
}, );
}); key_vertex_entries.reserve(
per_wire
.iter()
.map(|entries| entries.key_vertices.len())
.sum(),
);
key_segment_entries.reserve(
per_wire
.iter()
.map(|entries| entries.key_segments.len())
.sum(),
);
fill_triangle_entries.reserve(
per_wire
.iter()
.map(|entries| entries.fill_triangles.len())
.sum(),
);
pick_triangle_entries.reserve(
per_wire
.iter()
.map(|entries| entries.pick_triangles.len())
.sum(),
);
glyph_entries.reserve(per_wire.iter().map(|entries| entries.glyphs.len()).sum());
for (wire_idx, mut entries) in per_wire.into_iter().enumerate() {
max_line_half_width_px =
max_line_half_width_px.max(entries.max_line_half_width_px);
if let Some(entry) = entries.wire {
wire_entries.push(entry);
} }
for (index, (point, _)) in wire.snap_pts.iter().enumerate() { if entries.unbounded {
if !point.is_finite() { unbounded_wires.push(wire_idx as u32);
continue;
}
snap_point_entries.push(Entry3 {
aabb: [point.x, point.y, point.z, point.x, point.y, point.z],
value: SnapPointRef {
wire: wire_idx,
index: index as u32,
},
});
}
for (index, &point) in wire.key_vertices.iter().enumerate() {
if !point.iter().all(|value| value.is_finite()) {
continue;
}
key_vertex_entries.push(Entry3 {
aabb: [point[0], point[1], point[2], point[0], point[1], point[2]],
value: KeyVertexRef {
wire: wire_idx,
index: index as u32,
},
});
}
for start in 0..wire.key_vertices.len().saturating_sub(1) {
let Some(aabb) =
points_aabb3([wire.key_vertices[start], wire.key_vertices[start + 1]])
else {
continue;
};
key_segment_entries.push(Entry3 {
aabb,
value: KeySegmentRef {
wire: wire_idx,
start: start as u32,
},
});
}
append_triangle_entries(
wire_idx,
&wire.fill_tris,
&wire.fill_tris_low,
&mut fill_triangle_entries,
);
append_triangle_entries(
wire_idx,
&wire.pick_tris,
&wire.pick_tris_low,
&mut pick_triangle_entries,
);
for start in (0..wire.text_verts.len()).step_by(6) {
let Some(quad) = wire.text_verts.get(start..start + 6) else {
break;
};
let Some(aabb) = points_aabb3(quad.iter().map(|vertex| {
[
vertex.pos[0] as f64 + vertex.pos_low[0] as f64,
vertex.pos[1] as f64 + vertex.pos_low[1] as f64,
vertex.pos[2] as f64 + vertex.pos_low[2] as f64,
]
})) else {
continue;
};
glyph_entries.push(Entry3 {
aabb,
value: GlyphRef {
wire: wire_idx,
start: start as u32,
},
});
} }
segment_entries.append(&mut entries.segments);
snap_point_entries.append(&mut entries.snap_points);
key_vertex_entries.append(&mut entries.key_vertices);
key_segment_entries.append(&mut entries.key_segments);
fill_triangle_entries.append(&mut entries.fill_triangles);
pick_triangle_entries.append(&mut entries.pick_triangles);
glyph_entries.append(&mut entries.glyphs);
} }
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]

View file

@ -149,7 +149,9 @@ pub struct Pipeline {
/// ref-counted, so N paper viewports (or Model tiles) drawing one identical /// ref-counted, so N paper viewports (or Model tiles) drawing one identical
/// resident set hold one copy of the GPU vertex buffers between them — /// resident set hold one copy of the GPU vertex buffers between them —
/// their camera uniforms + scissor stay per-slot, only the geometry is /// their camera uniforms + scissor stay per-slot, only the geometry is
/// deduplicated. Never mutated in place, only reassigned on content change. /// deduplicated. Never mutated in place; an arena-backed slot may also
/// replace this thin draw-range list on camera changes without touching the
/// shared resident buffer.
pub(crate) gpu_wires: std::sync::Arc<Vec<WireGpu>>, pub(crate) gpu_wires: std::sync::Arc<Vec<WireGpu>>,
/// Persistent per-entity wire instance arena (native, `OCS_WIRE_GPU_PATCH`). /// Persistent per-entity wire instance arena (native, `OCS_WIRE_GPU_PATCH`).
/// When active, `gpu_wires` is a thin wrapper over this arena's buffers and an /// When active, `gpu_wires` is a thin wrapper over this arena's buffers and an
@ -164,6 +166,10 @@ pub struct Pipeline {
/// The Model content id both arenas currently mirror (`u64::MAX` = none). /// The Model content id both arenas currently mirror (`u64::MAX` = none).
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub(crate) wire_arena_id: u64, pub(crate) wire_arena_id: u64,
/// Last content/camera/viewport tuple used to derive visible instance
/// ranges from the resident arena.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) wire_cull_key: (u64, u64, u32, u32),
/// This content viewport's non-rectangular clip boundary as a triangle-fan /// This content viewport's non-rectangular clip boundary as a triangle-fan
/// vertex buffer in the render target's normalized device coords (`None` = /// vertex buffer in the render target's normalized device coords (`None` =
/// rectangular / unclipped, where the viewport's own render rectangle does /// rectangular / unclipped, where the viewport's own render rectangle does
@ -1450,6 +1456,8 @@ impl Pipeline {
wire_arena_mesh: None, wire_arena_mesh: None,
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
wire_arena_id: u64::MAX, wire_arena_id: u64::MAX,
#[cfg(not(target_arch = "wasm32"))]
wire_cull_key: (u64::MAX, u64::MAX, 0, 0),
clip_boundary: None, clip_boundary: None,
gpu_selected_wires: vec![], gpu_selected_wires: vec![],
gpu_preview_wires: vec![], gpu_preview_wires: vec![],
@ -2572,7 +2580,10 @@ impl Pipeline {
pass.set_bind_group(1, bg.as_ref(), &[]); pass.set_bind_group(1, bg.as_ref(), &[]);
} }
pass.set_vertex_buffer(0, edges.instance_buffer.slice(..)); pass.set_vertex_buffer(0, edges.instance_buffer.slice(..));
pass.draw(0..6, 0..edges.instance_count); pass.draw(
0..6,
edges.first_instance..edges.first_instance + edges.instance_count,
);
} }
} }
} }
@ -2638,7 +2649,10 @@ impl Pipeline {
pass.set_bind_group(1, bg.as_ref(), &[]); pass.set_bind_group(1, bg.as_ref(), &[]);
} }
pass.set_vertex_buffer(0, wire.instance_buffer.slice(..)); pass.set_vertex_buffer(0, wire.instance_buffer.slice(..));
pass.draw(0..6, 0..wire.instance_count); pass.draw(
0..6,
wire.first_instance..wire.first_instance + wire.instance_count,
);
} }
// Live overlay wires (command preview / interim / grip drag) always // Live overlay wires (command preview / interim / grip drag) always
// on top: the xray pipeline (depth_compare=Always, no depth write) // on top: the xray pipeline (depth_compare=Always, no depth write)
@ -2653,7 +2667,10 @@ impl Pipeline {
pass.set_bind_group(1, bg.as_ref(), &[]); pass.set_bind_group(1, bg.as_ref(), &[]);
} }
pass.set_vertex_buffer(0, pw.instance_buffer.slice(..)); pass.set_vertex_buffer(0, pw.instance_buffer.slice(..));
pass.draw(0..6, 0..pw.instance_count); pass.draw(
0..6,
pw.first_instance..pw.first_instance + pw.instance_count,
);
} }
} }
} }
@ -2791,7 +2808,10 @@ impl Pipeline {
pass.set_bind_group(1, bg.as_ref(), &[]); pass.set_bind_group(1, bg.as_ref(), &[]);
} }
pass.set_vertex_buffer(0, wire.instance_buffer.slice(..)); pass.set_vertex_buffer(0, wire.instance_buffer.slice(..));
pass.draw(0..6, 0..wire.instance_count); pass.draw(
0..6,
wire.first_instance..wire.first_instance + wire.instance_count,
);
} }
} }
} }

View file

@ -62,6 +62,9 @@ struct Slab {
inst_len: u32, inst_len: u32,
const_off: u32, const_off: u32,
const_len: u32, const_len: u32,
/// World-XY bounds for plan-view draw-range culling. Unbounded whenever a
/// source wire does not carry a trustworthy entity AABB.
aabb: [f32; 4],
/// Entity-level draw depth used when this slab was emitted. Individual /// Entity-level draw depth used when this slab was emitted. Individual
/// consts may carry block-local offsets around it; structural edits shift /// consts may carry block-local offsets around it; structural edits shift
/// the whole slab by the base-depth delta instead of flattening those /// the whole slab by the base-depth delta instead of flattening those
@ -182,6 +185,37 @@ fn handle_ranges(wires: &[&WireModel]) -> Option<Vec<(Handle, usize, usize)>> {
Some(out) Some(out)
} }
fn run_aabb(wires: &[&WireModel]) -> [f32; 4] {
let mut out = [
f32::INFINITY,
f32::INFINITY,
f32::NEG_INFINITY,
f32::NEG_INFINITY,
];
for wire in wires {
let [x0, y0, x1, y1] = wire.aabb;
if !x0.is_finite()
|| !y0.is_finite()
|| !x1.is_finite()
|| !y1.is_finite()
|| x0 > x1
|| y0 > y1
{
return WireModel::UNBOUNDED_AABB;
}
let pad = (wire.world_width * 0.5).max(0.0);
out[0] = out[0].min(x0 - pad);
out[1] = out[1].min(y0 - pad);
out[2] = out[2].max(x1 + pad);
out[3] = out[3].max(y1 + pad);
}
if out[0].is_finite() {
out
} else {
WireModel::UNBOUNDED_AABB
}
}
fn make_const_bg( fn make_const_bg(
device: &wgpu::Device, device: &wgpu::Device,
bgl: &wgpu::BindGroupLayout, bgl: &wgpu::BindGroupLayout,
@ -197,22 +231,50 @@ fn make_const_bg(
})) }))
} }
fn alloc_inst(device: &wgpu::Device, cap: u64) -> wgpu::Buffer { fn alloc_inst_initialized(
device.create_buffer(&wgpu::BufferDescriptor { device: &wgpu::Device,
cap: u64,
data: &[WireInstance],
) -> wgpu::Buffer {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("wire_arena.ibuf"), label: Some("wire_arena.ibuf"),
size: cap * std::mem::size_of::<WireInstance>() as u64, size: cap * std::mem::size_of::<WireInstance>() as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false, mapped_at_creation: true,
}) });
if !data.is_empty() {
let bytes = bytemuck::cast_slice(data);
let mut mapped = buffer
.slice(..bytes.len() as u64)
.get_mapped_range_mut();
mapped.copy_from_slice(bytes);
drop(mapped);
}
buffer.unmap();
buffer
} }
fn alloc_const(device: &wgpu::Device, cap: u64) -> wgpu::Buffer { fn alloc_const_initialized(
device.create_buffer(&wgpu::BufferDescriptor { device: &wgpu::Device,
cap: u64,
data: &[WireConst],
) -> wgpu::Buffer {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("wire_arena.cbuf"), label: Some("wire_arena.cbuf"),
size: cap * std::mem::size_of::<WireConst>() as u64, size: cap * std::mem::size_of::<WireConst>() as u64,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false, mapped_at_creation: true,
}) });
if !data.is_empty() {
let bytes = bytemuck::cast_slice(data);
let mut mapped = buffer
.slice(..bytes.len() as u64)
.get_mapped_range_mut();
mapped.copy_from_slice(bytes);
drop(mapped);
}
buffer.unmap();
buffer
} }
fn blank_const() -> WireConst { fn blank_const() -> WireConst {
@ -230,8 +292,7 @@ fn blank_instance() -> WireInstance {
distance_a: 0.0, distance_a: 0.0,
distance_b: 0.0, distance_b: 0.0,
wire_id: 0, wire_id: 0,
world_hw_a: 0.0, taper_ratio: [0; 2],
world_hw_b: 0.0,
} }
} }
@ -241,44 +302,128 @@ impl WireArena {
/// path). /// path).
pub fn build( pub fn build(
device: &wgpu::Device, device: &wgpu::Device,
queue: &wgpu::Queue, _queue: &wgpu::Queue,
wires: &[&WireModel], wires: &[&WireModel],
depth_map: &FxHashMap<u64, [f32; 2]>, depth_map: &FxHashMap<u64, [f32; 2]>,
const_bgl: &wgpu::BindGroupLayout, const_bgl: &wgpu::BindGroupLayout,
mesh_edge: bool, mesh_edge: bool,
) -> Option<Self> { ) -> Option<Self> {
let ranges = handle_ranges(wires)?; let ranges = handle_ranges(wires)?;
let perf = std::env::var_os("OCS_PERF").is_some();
let total_started = std::time::Instant::now();
// Reject an oversized batch before parallel emission allocates hundreds
// of megabytes. `points.len() - 1` is an upper bound because NaN-break
// segments are skipped by emit_wire_native.
let max_instances: usize = wires
.iter()
.map(|w| w.points.len().saturating_sub(1))
.sum();
if max_instances as u64 > MAX_INSTANCES || wires.len() as u64 + 1 > MAX_CONSTS {
return None;
}
struct BuildPlan {
handle: Handle,
start: usize,
end: usize,
const_off: u32,
base_depth: f32,
}
struct PackedSlab {
handle: Handle,
const_off: u32,
base_depth: f32,
aabb: [f32; 4],
instances: Vec<WireInstance>,
consts: Vec<WireConst>,
}
// Assign global const slots serially so parallel workers can emit final
// wire_id values directly. Indexed parallel collect preserves handle and
// wire submission order.
let mut next_const = 1u32;
let plans: Vec<BuildPlan> = ranges
.into_iter()
.map(|(handle, start, end)| {
let const_off = next_const;
next_const += (end - start) as u32;
let base_depth = if mesh_edge {
0.0
} else {
depth_map.get(&handle.value()).map_or(0.0, |d| d[0])
};
BuildPlan {
handle,
start,
end,
const_off,
base_depth,
}
})
.collect();
let pack_started = std::time::Instant::now();
use crate::par::prelude::*;
let packed: Vec<PackedSlab> = plans
.par_iter()
.map(|plan| {
let run = &wires[plan.start..plan.end];
let capacity: usize = run
.iter()
.map(|w| w.points.len().saturating_sub(1))
.sum();
let mut instances: Vec<WireInstance> = Vec::with_capacity(capacity);
let mut consts: Vec<WireConst> = Vec::with_capacity(run.len());
for (local, &w) in run.iter().enumerate() {
let wire_id = plan.const_off + local as u32;
// 3D mesh outline edges are occluded by true depth and must NOT
// take the draw-order z-bias (or hidden back edges peek through
// the shaded fill) — matching WireGpu::from_run.
let dd = if mesh_edge { 0.0 } else { wire_draw_depth(w, depth_map) };
let (mut emitted, cst) = emit_wire_native(w, wire_id, w.color, dd);
instances.append(&mut emitted);
consts.push(cst);
}
PackedSlab {
handle: plan.handle,
const_off: plan.const_off,
base_depth: plan.base_depth,
aabb: run_aabb(run),
instances,
consts,
}
})
.collect();
let pack_ms = pack_started.elapsed().as_secs_f64() * 1000.0;
let inst_count: usize = packed.iter().map(|slab| slab.instances.len()).sum();
let const_count: usize = 1 + packed.iter().map(|slab| slab.consts.len()).sum::<usize>();
if inst_count as u64 > MAX_INSTANCES || const_count as u64 > MAX_CONSTS {
return None;
}
// const slot 0 = blank tombstone target. // const slot 0 = blank tombstone target.
let mut instances: Vec<WireInstance> = Vec::new(); let mut instances: Vec<WireInstance> = Vec::with_capacity(inst_count);
let mut consts_cpu: Vec<WireConst> = vec![blank_const()]; let mut consts_cpu: Vec<WireConst> = Vec::with_capacity(const_count);
let mut slabs: FxHashMap<Handle, Slab> = FxHashMap::default(); consts_cpu.push(blank_const());
for (h, i, j) in ranges { let mut slabs: FxHashMap<Handle, Slab> =
FxHashMap::with_capacity_and_hasher(packed.len(), Default::default());
for mut packed_slab in packed {
let inst_off = instances.len() as u32; let inst_off = instances.len() as u32;
let const_off = consts_cpu.len() as u32; let inst_len = packed_slab.instances.len() as u32;
let base_depth = if mesh_edge { let const_len = packed_slab.consts.len() as u32;
0.0 instances.append(&mut packed_slab.instances);
} else { consts_cpu.append(&mut packed_slab.consts);
depth_map.get(&h.value()).map_or(0.0, |d| d[0])
};
for &w in &wires[i..j] {
let wire_id = consts_cpu.len() as u32;
// 3D mesh outline edges are occluded by true depth and must NOT
// take the draw-order z-bias (or hidden back edges peek through
// the shaded fill) — matching WireGpu::from_run.
let dd = if mesh_edge { 0.0 } else { wire_draw_depth(w, depth_map) };
let (mut insts, cst) = emit_wire_native(w, wire_id, w.color, dd);
instances.append(&mut insts);
consts_cpu.push(cst);
}
slabs.insert( slabs.insert(
h, packed_slab.handle,
Slab { Slab {
inst_off, inst_off,
inst_len: instances.len() as u32 - inst_off, inst_len,
const_off, const_off: packed_slab.const_off,
const_len: consts_cpu.len() as u32 - const_off, const_len,
base_depth, aabb: packed_slab.aabb,
base_depth: packed_slab.base_depth,
}, },
); );
} }
@ -296,13 +441,24 @@ impl WireArena {
let const_cap = ((const_tail as u64 * HEADROOM_NUM / HEADROOM_DEN) let const_cap = ((const_tail as u64 * HEADROOM_NUM / HEADROOM_DEN)
.max(MIN_CONST_CAP) .max(MIN_CONST_CAP)
.min(MAX_CONSTS)) as u32; .min(MAX_CONSTS)) as u32;
let inst_buf = alloc_inst(device, inst_cap as u64); let upload_started = std::time::Instant::now();
let const_buf = alloc_const(device, const_cap as u64); let inst_buf = alloc_inst_initialized(device, inst_cap as u64, &instances);
if inst_tail > 0 { let const_buf = alloc_const_initialized(device, const_cap as u64, &consts_cpu);
queue.write_buffer(&inst_buf, 0, bytemuck::cast_slice(&instances)); let upload_ms = upload_started.elapsed().as_secs_f64() * 1000.0;
}
queue.write_buffer(&const_buf, 0, bytemuck::cast_slice(&consts_cpu));
let const_bind_group = make_const_bg(device, const_bgl, &const_buf); let const_bind_group = make_const_bg(device, const_bgl, &const_buf);
if perf {
eprintln!(
"[perf] arena-build-detail total={:.1}ms pack={:.1} mapped-upload={:.1} handles={} wires={} instances={} instance-bytes={} consts={}",
total_started.elapsed().as_secs_f64() * 1000.0,
pack_ms,
upload_ms,
slabs.len(),
wires.len(),
inst_tail,
inst_tail as usize * std::mem::size_of::<WireInstance>(),
const_tail,
);
}
Some(Self { Some(Self {
inst_buf, inst_buf,
@ -381,6 +537,7 @@ impl WireArena {
} else { } else {
depth_map.get(&h.value()).map_or(0.0, |d| d[0]) depth_map.get(&h.value()).map_or(0.0, |d| d[0])
}; };
let aabb = run_aabb(run);
if !self.slabs.contains_key(&h) if !self.slabs.contains_key(&h)
&& self && self
@ -419,7 +576,9 @@ impl WireArena {
const_off as u64 * csz, const_off as u64 * csz,
bytemuck::cast_slice(&csts), bytemuck::cast_slice(&csts),
); );
self.slabs.get_mut(&h).unwrap().base_depth = base_depth; let slab = self.slabs.get_mut(&h).unwrap();
slab.base_depth = base_depth;
slab.aabb = aabb;
continue; continue;
} }
@ -465,6 +624,7 @@ impl WireArena {
inst_len, inst_len,
const_off, const_off,
const_len, const_len,
aabb,
base_depth, base_depth,
}, },
); );
@ -509,9 +669,101 @@ impl WireArena {
} }
vec![WireGpu { vec![WireGpu {
instance_buffer: self.inst_buf.clone(), instance_buffer: self.inst_buf.clone(),
first_instance: 0,
instance_count: self.inst_tail, instance_count: self.inst_tail,
is_3d_mesh_edge: self.mesh_edge, is_3d_mesh_edge: self.mesh_edge,
const_bind_group: Some(self.const_bind_group.clone()), const_bind_group: Some(self.const_bind_group.clone()),
}] }]
} }
/// Draw only plan-view entity ranges that can reach the viewport. The
/// instance buffer stays fully resident, so pan/zoom changes only this tiny
/// list of offsets — no repack and no GPU upload. Non-plan views use the
/// conservative full draw because a 2-D entity AABB does not contain Z.
pub fn wire_gpus_visible(
&self,
view_rot: glam::Mat4,
eye: glam::DVec3,
clip_w: u32,
clip_h: u32,
) -> Vec<WireGpu> {
if self.inst_tail == 0 {
return vec![];
}
let projected_x = view_rot.transform_vector3(glam::Vec3::X);
let projected_y = view_rot.transform_vector3(glam::Vec3::Y);
let projected_z = view_rot.transform_vector3(glam::Vec3::Z);
let xy_scale = projected_x
.truncate()
.length()
.max(projected_y.truncate().length())
.max(f32::MIN_POSITIVE);
if projected_z.truncate().length() > xy_scale * 1e-5 {
return self.wire_gpus();
}
let mut ranges: Vec<(u32, u32)> = self
.slabs
.values()
.filter(|slab| {
slab.inst_len > 0
&& !super::aabb_offscreen(slab.aabb, view_rot, eye, clip_w, clip_h)
})
.map(|slab| (slab.inst_off, slab.inst_off + slab.inst_len))
.collect();
ranges.sort_unstable_by_key(|range| range.0);
let mut merged: Vec<(u32, u32)> = Vec::with_capacity(ranges.len());
for (start, end) in ranges {
if let Some((_, previous_end)) = merged.last_mut() {
if *previous_end == start {
*previous_end = end;
continue;
}
}
merged.push((start, end));
}
let mut ranges = merged;
if ranges.is_empty() {
return vec![];
}
// Cap CPU draw-call overhead on pathologically interleaved draw order.
// Merging a few separated visible spans draws their offscreen gap too,
// but retains order and still avoids the rest of a multi-million
// instance drawing.
const MAX_RANGES: usize = 64;
if ranges.len() > MAX_RANGES {
let group = (ranges.len() + MAX_RANGES - 1) / MAX_RANGES;
ranges = ranges
.chunks(group)
.map(|chunk| (chunk[0].0, chunk[chunk.len() - 1].1))
.collect();
}
if std::env::var_os("OCS_PERF").is_some() {
let submitted: u64 = ranges
.iter()
.map(|(start, end)| (end - start) as u64)
.sum();
if submitted < self.inst_tail as u64 {
eprintln!(
"[perf] wire-cull submitted={} resident={} ranges={}",
submitted,
self.inst_tail,
ranges.len(),
);
}
}
ranges
.into_iter()
.map(|(start, end)| WireGpu {
instance_buffer: self.inst_buf.clone(),
first_instance: start,
instance_count: end - start,
is_3d_mesh_edge: self.mesh_edge,
const_bind_group: Some(self.const_bind_group.clone()),
})
.collect()
}
} }

View file

@ -65,9 +65,10 @@ fn instance_buffer_mapped<T: bytemuck::Pod>(
// even though it's constant along the wire. On native we hoist those into a // even though it's constant along the wire. On native we hoist those into a
// per-wire `WireConst` storage buffer indexed by `wire_id`, so the instance // per-wire `WireConst` storage buffer indexed by `wire_id`, so the instance
// keeps only the per-segment data (endpoints + arc-length distances). Cuts the // keeps only the per-segment data (endpoints + arc-length distances). Cuts the
// instance from 104 B to 60 B (~42 %) and removes the redundant per-segment // instance from 104 B to one 64-byte cache line and removes the redundant
// re-fetch of the shared constants. WebGL2 has no vertex-stage storage buffers, // per-segment re-fetch of the shared constants. WebGL2 has no vertex-stage
// so the wasm build below keeps the original self-contained fat instance. // storage buffers, so the wasm build below keeps the original self-contained
// fat instance.
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
#[repr(C)] #[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
@ -80,11 +81,11 @@ pub struct WireInstance {
pub distance_b: f32, pub distance_b: f32,
/// Index into the per-wire `WireConst` storage buffer (group 1). /// Index into the per-wire `WireConst` storage buffer (group 1).
pub wire_id: u32, pub wire_id: u32,
/// World-space half-width at each endpoint for a TAPERED band. `0.0` = /// Endpoint width / the per-wire maximum width, normalized by the vertex
/// use the per-wire constant (`WireConst.world_half_width`). The shader /// fetch unit. `[0, 0]` means use the constant width. Ratios retain the
/// interpolates between the two so the band tapers across the segment. /// full f32 world-width scale in `WireConst` while making every instance
pub world_hw_a: f32, /// exactly one 64-byte cache line.
pub world_hw_b: f32, pub taper_ratio: [u16; 2],
} }
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
@ -99,8 +100,7 @@ impl WireInstance {
wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, distance_a) as u64, shader_location: 4, format: wgpu::VertexFormat::Float32 }, wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, distance_a) as u64, shader_location: 4, format: wgpu::VertexFormat::Float32 },
wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, distance_b) as u64, shader_location: 5, format: wgpu::VertexFormat::Float32 }, wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, distance_b) as u64, shader_location: 5, format: wgpu::VertexFormat::Float32 },
wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, wire_id) as u64, shader_location: 6, format: wgpu::VertexFormat::Uint32 }, wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, wire_id) as u64, shader_location: 6, format: wgpu::VertexFormat::Uint32 },
wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, world_hw_a) as u64, shader_location: 7, format: wgpu::VertexFormat::Float32 }, wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, taper_ratio) as u64, shader_location: 7, format: wgpu::VertexFormat::Unorm16x2 },
wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, world_hw_b) as u64, shader_location: 8, format: wgpu::VertexFormat::Float32 },
]; ];
wgpu::VertexBufferLayout { wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<WireInstance>() as u64, array_stride: std::mem::size_of::<WireInstance>() as u64,
@ -284,6 +284,8 @@ impl WirePipelineMode {
pub struct WireGpu { pub struct WireGpu {
pub instance_buffer: wgpu::Buffer, pub instance_buffer: wgpu::Buffer,
/// First instance in a shared arena buffer. Standalone buffers start at 0.
pub first_instance: u32,
pub instance_count: u32, pub instance_count: u32,
/// `true` when the source `WireModel` also carries `fill_tris` /// `true` when the source `WireModel` also carries `fill_tris`
/// (i.e. it is a 3D mesh face — PolyfaceMesh / PolygonMesh — whose /// (i.e. it is a 3D mesh face — PolyfaceMesh / PolygonMesh — whose
@ -520,9 +522,20 @@ pub(crate) fn emit_wire_native(
return (Vec::new(), cst); return (Vec::new(), cst);
} }
let low = |i: usize| -> [f32; 3] { wire.points_low.get(i).copied().unwrap_or([0.0; 3]) }; let low = |i: usize| -> [f32; 3] { wire.points_low.get(i).copied().unwrap_or([0.0; 3]) };
// Per-point half-width for a tapered band (empty ⇒ 0 ⇒ shader uses the // Store an endpoint/max-width ratio. The shared f32 maximum keeps drawing
// per-wire constant width). // units and range out of the packed field; UNORM16 contributes only a
let tw = |i: usize| -> f32 { wire.taper_widths.get(i).copied().unwrap_or(0.0) * 0.5 }; // relative error below 1/65535. Preserve zero as the existing constant
// width fallback sentinel.
let taper_ratio = |i: usize| -> u16 {
let width = wire.taper_widths.get(i).copied().unwrap_or(0.0);
if width <= 0.0 || wire.world_width <= 0.0 {
0
} else {
((width / wire.world_width).clamp(0.0, 1.0) * u16::MAX as f32)
.round()
.max(1.0) as u16
}
};
let mut instances: Vec<WireInstance> = Vec::with_capacity(seg_count); let mut instances: Vec<WireInstance> = Vec::with_capacity(seg_count);
for i in 0..seg_count { for i in 0..seg_count {
let a = wire.points[i]; let a = wire.points[i];
@ -538,8 +551,7 @@ pub(crate) fn emit_wire_native(
distance_a: dists[i], distance_a: dists[i],
distance_b: dists[i + 1], distance_b: dists[i + 1],
wire_id, wire_id,
world_hw_a: tw(i), taper_ratio: [taper_ratio(i), taper_ratio(i + 1)],
world_hw_b: tw(i + 1),
}); });
} }
(instances, cst) (instances, cst)
@ -652,6 +664,7 @@ impl WireGpu {
let buf = instance_buffer_mapped(device, "wire.run.ibuf", chunk); let buf = instance_buffer_mapped(device, "wire.run.ibuf", chunk);
Self { Self {
instance_buffer: buf, instance_buffer: buf,
first_instance: 0,
instance_count: chunk.len() as u32, instance_count: chunk.len() as u32,
is_3d_mesh_edge: mesh_edge, is_3d_mesh_edge: mesh_edge,
const_bind_group: Some(bg.clone()), const_bind_group: Some(bg.clone()),
@ -684,6 +697,7 @@ impl WireGpu {
let buf = instance_buffer_mapped(device, "wire.run.compat.ibuf", chunk); let buf = instance_buffer_mapped(device, "wire.run.compat.ibuf", chunk);
Self { Self {
instance_buffer: buf, instance_buffer: buf,
first_instance: 0,
instance_count: chunk.len() as u32, instance_count: chunk.len() as u32,
is_3d_mesh_edge: mesh_edge, is_3d_mesh_edge: mesh_edge,
const_bind_group: None, const_bind_group: None,
@ -740,6 +754,7 @@ impl WireGpu {
let instance_buffer = instance_buffer_mapped(device, &label, chunk); let instance_buffer = instance_buffer_mapped(device, &label, chunk);
Self { Self {
instance_buffer, instance_buffer,
first_instance: 0,
instance_count: chunk.len() as u32, instance_count: chunk.len() as u32,
is_3d_mesh_edge: false, is_3d_mesh_edge: false,
const_bind_group: Some(bg.clone()), const_bind_group: Some(bg.clone()),
@ -766,6 +781,7 @@ impl WireGpu {
let instance_buffer = instance_buffer_mapped(device, &label, chunk); let instance_buffer = instance_buffer_mapped(device, &label, chunk);
Self { Self {
instance_buffer, instance_buffer,
first_instance: 0,
instance_count: chunk.len() as u32, instance_count: chunk.len() as u32,
is_3d_mesh_edge: false, is_3d_mesh_edge: false,
const_bind_group: None, const_bind_group: None,

View file

@ -271,6 +271,10 @@ impl shader::Primitive for Primitive {
inner.cached_mesh_source = None; inner.cached_mesh_source = None;
inner.cached_face3d_source = None; inner.cached_face3d_source = None;
inner.cached_face3d_depth_source = None; inner.cached_face3d_depth_source = None;
#[cfg(not(target_arch = "wasm32"))]
{
inner.wire_cull_key = (u64::MAX, u64::MAX, 0, 0);
}
inner.render_sig = u64::MAX; inner.render_sig = u64::MAX;
} }
// The MSAA / depth / resolve textures are always sized to the // The MSAA / depth / resolve textures are always sized to the
@ -709,6 +713,41 @@ impl shader::Primitive for Primitive {
inner.compute_hatch_lod(queue, view_rot, eye, clip_size.width, clip_size.height); inner.compute_hatch_lod(queue, view_rot, eye, clip_size.width, clip_size.height);
inner.compute_wipeout_lod(view_rot, eye, clip_size.width, clip_size.height); inner.compute_wipeout_lod(view_rot, eye, clip_size.width, clip_size.height);
inner.compute_mesh_lod(view_rot, eye, clip_size.width, clip_size.height); inner.compute_mesh_lod(view_rot, eye, clip_size.width, clip_size.height);
#[cfg(not(target_arch = "wasm32"))]
{
let cull_key = (
vp.wire_content_id,
vp.camera_generation,
clip_size.width,
clip_size.height,
);
if inner.wire_arena_id == vp.wire_content_id
&& inner.wire_cull_key != cull_key
{
let mut visible = inner
.wire_arena
.as_ref()
.map(|arena| {
arena.wire_gpus_visible(
view_rot,
eye,
clip_size.width,
clip_size.height,
)
})
.unwrap_or_default();
if let Some(arena) = inner.wire_arena_mesh.as_ref() {
visible.extend(arena.wire_gpus_visible(
view_rot,
eye,
clip_size.width,
clip_size.height,
));
}
inner.gpu_wires = std::sync::Arc::new(visible);
inner.wire_cull_key = cull_key;
}
}
if vp.show_viewcube { if vp.show_viewcube {
inner.viewcube.upload( inner.viewcube.upload(
queue, queue,

View file

@ -1,9 +1,9 @@
// Wire shader (native) same as wire.wgsl, but the per-wire constants // Wire shader (native) same as wire.wgsl, but the per-wire constants
// (color / half_width / dash pattern / draw_depth) live in a per-wire storage // (color / half_width / dash pattern / draw_depth) live in a per-wire storage
// buffer indexed by `wire_id` instead of being replicated on every segment // buffer indexed by `wire_id` instead of being replicated on every segment
// instance. Cuts the instance from 104 B to 60 B and removes the redundant // instance. Cuts the instance from 104 B to one 64-byte cache line and removes
// per-segment re-fetch of constants. WebGL2 has no vertex-stage storage // the redundant per-segment re-fetch of constants. WebGL2 has no vertex-stage
// buffers, so the wasm build uses wire.wgsl (fat instance) instead. // storage buffers, so the wasm build uses wire.wgsl (fat instance) instead.
struct Uniforms { struct Uniforms {
viewport_size: vec2<f32>, viewport_size: vec2<f32>,
@ -44,10 +44,9 @@ struct InstanceIn {
@location(4) distance_a: f32, @location(4) distance_a: f32,
@location(5) distance_b: f32, @location(5) distance_b: f32,
@location(6) wire_id: u32, @location(6) wire_id: u32,
// Per-endpoint world half-width for a tapered band (0 = use the per-wire // Per-endpoint width / per-wire maximum width. Vertex UNORM16 conversion
// constant `world_half_width`). // expands this to 0..1; zero keeps the constant-width fallback.
@location(7) world_hw_a: f32, @location(7) taper_ratio: vec2<f32>,
@location(8) world_hw_b: f32,
} }
const DRAW_ORDER_BIAS: f32 = 0.001; const DRAW_ORDER_BIAS: f32 = 0.001;
@ -73,8 +72,10 @@ struct VertexOut {
// Half-width of one segment end: a tapered band's own end width wins, then a // Half-width of one segment end: a tapered band's own end width wins, then a
// constant world-unit band, then the screen-pixel lineweight (LWDISPLAY off // constant world-unit band, then the screen-pixel lineweight (LWDISPLAY off
// collapses to a hairline). // collapses to a hairline).
fn resolve_hw(taper: f32, world_hw: f32, px_hw: f32) -> f32 { fn resolve_hw(taper_ratio: f32, world_hw: f32, px_hw: f32) -> f32 {
if taper > 0.0 { return max(taper / u.world_per_pixel, 0.5); } if taper_ratio > 0.0 {
return max((taper_ratio * world_hw) / u.world_per_pixel, 0.5);
}
if world_hw > 0.0 { return max(world_hw / u.world_per_pixel, 0.5); } if world_hw > 0.0 { return max(world_hw / u.world_per_pixel, 0.5); }
return select(0.5, px_hw, u.lwdisplay_enable > 0.5); return select(0.5, px_hw, u.lwdisplay_enable > 0.5);
} }
@ -114,12 +115,12 @@ fn resolve_hw(taper: f32, world_hw: f32, px_hw: f32) -> f32 {
// by `world_half_width / world_per_pixel` (screen pixels) so the band grows // by `world_half_width / world_per_pixel` (screen pixels) so the band grows
// and shrinks with zoom. A normal wire (world_half_width == 0) uses the // and shrinks with zoom. A normal wire (world_half_width == 0) uses the
// screen-pixel half-width, honouring the LWDISPLAY toggle. // screen-pixel half-width, honouring the LWDISPLAY toggle.
// A tapered band carries a per-endpoint world half-width on the instance: // A tapered band carries normalized endpoint widths on the instance:
// interpolate across the segment so the band narrows/widens smoothly. A // interpolate across the segment so the band narrows/widens smoothly. A
// constant band uses the per-wire `world_half_width`. Both clamp to a // constant band uses the per-wire `world_half_width`. Both clamp to a
// half-pixel so a zoomed-out band stays a hairline instead of vanishing. // half-pixel so a zoomed-out band stays a hairline instead of vanishing.
let hw_a = resolve_hw(in.world_hw_a, c.world_half_width, c.half_width); let hw_a = resolve_hw(in.taper_ratio.x, c.world_half_width, c.half_width);
let hw_b = resolve_hw(in.world_hw_b, c.world_half_width, c.half_width); let hw_b = resolve_hw(in.taper_ratio.y, c.world_half_width, c.half_width);
let hw = mix(hw_a, hw_b, which_end); let hw = mix(hw_a, hw_b, which_end);
// Extend the quad longitudinally by the end half-width and let the // Extend the quad longitudinally by the end half-width and let the