perf: optimize large drawing workflows

This commit is contained in:
Hakan Seven committed 2026-07-25 02:58:46 +03:00
1 parent 1c7e4bb850
commit 71e0b88839
17 files changed
+1204 -430

No files matched your search

Generated
+3 -1
View File
@@ -74,7 +74,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
[[package]]
name = "acadrust"
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 = [
"ahash 0.8.12",
"anyhow",
@@ -84,9 +84,11 @@ dependencies = [
"flate2",
"indexmap",
"itoa",
"memmap2",
"nalgebra",
"nom 7.1.3",
"once_cell",
"rayon",
"ryu",
"serde",
"thiserror 1.0.69",
+3 -6
View File
@@ -86,14 +86,11 @@ lyon_tessellation = "1.0.20"
# (IApplicationAssociationRegistrationUI dialog).
# Win32_System_Registry — HKCU registration that lists the app under
# "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]
# The `acadrust` crate now tracks the OpenAEC-Foundation/acadifc fork's main
# (still the `acadrust` package): the in-flight DWG reader work plus the Arc
# 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" }
# Track the DWG reader/writer performance work used by this release.
acadrust = { git = "https://github.com/OpenAEC-Foundation/acadifc", rev = "f045889e0243d88b6905124907487e0c0a1bf0e3" }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
# Native enables the plugin host runtime (out-of-process plugins).
+11
View File
@@ -13,8 +13,11 @@ use iced;
use std::any::Any;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
static NEXT_DOCUMENT_TAB_ID: AtomicU64 = AtomicU64::new(1);
// ── Dynamic input ──────────────────────────────────────────────────────────
/// One quantity shown in the dynamic-input overlay near the cursor.
@@ -154,9 +157,15 @@ impl 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) current_path: Option<PathBuf>,
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) properties: PropertiesPanel,
pub(super) layers: LayerPanel,
@@ -413,9 +422,11 @@ impl DocumentTab {
}
}
Self {
id: NEXT_DOCUMENT_TAB_ID.fetch_add(1, Ordering::Relaxed),
scene,
current_path: None,
dirty: false,
edit_revision: 0,
prev_selection: Vec::new(),
tab_title: format!("Drawing{}", n),
properties: PropertiesPanel::empty(),
+2
View File
@@ -117,6 +117,7 @@ impl OpenCADStudio {
fn push_undo_entry(&mut self, i: usize, snapshot: HistorySnapshot) {
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.trim_history(i);
}
@@ -480,6 +481,7 @@ impl OpenCADStudio {
had_full: bool,
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;
if had_full {
+37 -2
View File
@@ -739,6 +739,10 @@ pub(super) struct OpenCADStudio {
// ── Unsaved-changes dialog ────────────────────────────────────────────
/// Set when the user tries to close a tab or quit while there are unsaved changes.
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.
// ── Custom Save-As dialog ─────────────────────────────────────────────
@@ -847,6 +851,35 @@ pub(super) enum PendingClose {
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.
#[derive(Debug, Clone)]
pub enum ColorPickTarget {
@@ -1404,8 +1437,8 @@ pub enum Message {
AecDropBack,
/// Periodic autosave tick — write `.sv$` recovery files for dirty tabs.
AutoSave,
/// Save-as path picked for the unsaved-changes → save → close flow.
UnsavedPickedSavePath(Option<std::path::PathBuf>),
/// Native background save/autosave completed.
SaveFinished(SaveOutcome),
// ─────────────────────────────────────────────────────────────────────
CommandInput(String),
CommandSubmit,
@@ -2418,6 +2451,8 @@ impl OpenCADStudio {
opening: None,
pending_opens: std::collections::VecDeque::new(),
pending_close: None,
active_save_jobs: std::collections::HashMap::new(),
save_job_serial: 0,
save_dialog_format: "DWG 2018".to_string(),
save_dialog_filename: "drawing.dwg".to_string(),
save_dialog_for_unsaved: false,
+76 -123
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> {
match self.pending_close.take() {
Some(crate::app::PendingClose::Tab(idx)) => {
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;
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 => {}
#[cfg(not(target_arch = "wasm32"))]
{
let Some(pending) = self.pending_close.clone() else {
return Task::none();
};
let (idx, continuation) = match pending {
crate::app::PendingClose::Tab(idx) => {
(idx, crate::app::SaveContinuation::CloseTab)
}
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()
}
}
+254 -69
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> {
if self.read_only {
self.command_line
@@ -775,37 +982,28 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
return Task::none();
}
let i = self.active_tab;
// Stamp the live grid/snap toggles onto the VPort so the file
// reflects them even if they came from settings with no
// in-session toggle (#121).
self.sync_vport_display(i);
// Persist Ortho ($ORTHOMODE) + running OSNAP ($OSMODE) into the
// drawing header so they survive save/reopen (per-drawing).
self.stamp_header_sysvars(i);
// Web serializes immediately below. Native preparation happens
// once after the destination/version is known.
#[cfg(target_arch = "wasm32")]
{
self.sync_vport_display(i);
self.stamp_header_sysvars(i);
}
// Native: save straight to the known path. Web has no path
// (downloads instead), so always go through the Save dialog.
#[cfg(not(target_arch = "wasm32"))]
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.
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;
self.stamp_thumbnail(i, ver);
match crate::io::save(&self.tabs[i].scene.document, &path) {
Ok(()) => {
self.command_line
.push_output(&format!("Saved: {}", path.display()));
self.tabs[i].dirty = false;
// A clean save supersedes any autosave recovery copy.
let _ = std::fs::remove_file(path.with_extension("sv$"));
}
Err(e) => self.command_line.push_error(&format!("Save failed: {e}")),
}
return Task::none();
self.prepare_native_save(i);
return self.queue_native_save(
i,
path,
ver,
crate::app::SavePurpose::Manual,
crate::app::SaveContinuation::None,
false,
);
}
self.save_dialog_for_unsaved = false;
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 i = self.active_tab;
sync_annotation_scale_header(&mut self.tabs[i].scene);
// Persist Ortho / running OSNAP into the header (Save-As path).
self.stamp_header_sysvars(i);
self.sync_truck_solids_to_acis(i);
self.stamp_thumbnail(i, version);
if self.backup_on_save {
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;
self.prepare_native_save(i);
let continuation = if self.save_dialog_for_unsaved {
match self.pending_close {
Some(crate::app::PendingClose::Tab(_)) => crate::app::SaveContinuation::CloseTab,
Some(crate::app::PendingClose::Quit) => crate::app::SaveContinuation::Quit,
None => crate::app::SaveContinuation::None,
}
Err(e) => self.command_line.push_error(&format!("Save failed: {e}")),
}
Task::none()
} else {
crate::app::SaveContinuation::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
@@ -1019,25 +1204,25 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
/// touches the original file or the dirty flag.
#[cfg(not(target_arch = "wasm32"))]
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() {
if !self.tabs[i].dirty {
if !self.tabs[i].dirty
|| self.active_save_jobs.contains_key(&self.tabs[i].id)
{
continue;
}
let version = self.tabs[i].scene.document.version;
if let Ok(bytes) =
crate::io::save_to_bytes(&self.tabs[i].scene.document, "dwg", version)
{
if std::fs::write(self.autosave_target(i), bytes).is_ok() {
n += 1;
}
}
let target = self.autosave_target(i);
tasks.push(self.queue_native_save(
i,
target,
version,
crate::app::SavePurpose::Autosave,
crate::app::SaveContinuation::None,
false,
));
}
if n > 0 {
self.command_line
.push_output(&format!("Autosaved {n} drawing(s)"));
}
Task::none()
Task::batch(tasks)
}
#[cfg(target_arch = "wasm32")]
+4 -10
View File
@@ -3770,17 +3770,11 @@ impl OpenCADStudio {
Message::AutoSave => self.on_autosave(),
Message::UnsavedPickedSavePath(Some(path)) => {
self.on_unsaved_picked_save_path_some(path)
}
#[cfg(not(target_arch = "wasm32"))]
Message::SaveFinished(outcome) => self.on_save_finished(outcome),
Message::UnsavedPickedSavePath(None) => {
// User cancelled the save-as dialog — re-open the confirmation dialog.
if self.pending_close.is_some() {
return self.open_unsaved_dialog_window();
}
Task::none()
}
#[cfg(target_arch = "wasm32")]
Message::SaveFinished(_) => Task::none(),
// ── Page Setup ────────────────────────────────────────────────
Message::UpdateCheckResult(latest) => {
+147 -6
View File
@@ -183,6 +183,12 @@ 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()
.map_err(|e| e.to_string())?;
#[cfg(target_arch = "wasm32")]
let mut doc = DwgReader::from_file(path)
.map_err(|e| e.to_string())?
.read()
@@ -418,19 +424,130 @@ pub fn save_as_version(
path: &Path,
version: acadrust::DxfVersion,
) -> 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;
let styles_started = std::time::Instant::now();
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);
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()
.map(|e| e.to_string_lossy().to_lowercase())
.unwrap_or_default();
match ext.as_str() {
let write_started = std::time::Instant::now();
let result = match ext.as_str() {
"dxf" => DxfWriter::new(&doc)
.write_to_file(path)
.write_to_file(&temp_path)
.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,
version: acadrust::DxfVersion,
) -> 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 clone_ms = clone_started.elapsed().as_secs_f64() * 1000.0;
doc.version = version;
let styles_started = std::time::Instant::now();
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);
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()),
_ => {
let mut buf = std::io::Cursor::new(Vec::new());
DwgWriter::write_to_writer(&mut buf, &doc).map_err(|e| e.to_string())?;
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
}
+17 -3
View File
@@ -15,6 +15,7 @@ use image::{ImageFormat, Rgb, RgbImage};
use std::io::Cursor;
use crate::scene::{Scene, WireModel};
use crate::scene::view::camera::Camera;
/// Longest edge of the generated thumbnail, in pixels.
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.
pub fn from_scene(scene: &Scene, png: bool, viewport: (f32, f32)) -> Option<Preview> {
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() {
return None;
}
@@ -49,9 +64,8 @@ pub fn from_scene(scene: &Scene, png: bool, viewport: (f32, f32)) -> Option<Prev
width: cw as f32,
height: ch as f32,
};
let cam = scene.camera.borrow();
rasterize(&wires, cw, ch, scene.bg_color, png, |x, y, z| {
cam.project(glam::DVec3::new(x, y, z), bounds)
rasterize(wires, cw, ch, bg_color, png, |x, y, z| {
camera.project(glam::DVec3::new(x, y, z), bounds)
.map(|s| (s.x.round() as i32, s.y.round() as i32))
})
}
+58 -38
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
/// 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.
fn sync_dimension_base_points(doc: &mut CadDocument) {
for e in doc.entities_mut() {
if let EntityType::Dimension(d) = e {
let def = match d {
Dimension::Linear(x) => Some(x.definition_point),
Dimension::Aligned(x) => Some(x.definition_point),
Dimension::Radius(x) => Some(x.definition_point),
Dimension::Diameter(x) => Some(x.definition_point),
Dimension::Ordinate(x) => Some(x.definition_point),
Dimension::Angular3Pt(x) => Some(x.definition_point),
fn sync_dimension_base_points_and_collect_pending(doc: &mut CadDocument) -> Vec<Handle> {
let existing_blocks: rustc_hash::FxHashSet<String> = doc
.block_records
.iter()
.map(|record| record.name.to_ascii_lowercase())
.collect();
let dimensions: Vec<(Handle, Option<Vector3>, bool)> = doc
.entities()
.filter_map(|entity| {
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,
};
if let Some(p) = def {
d.base_mut().definition_point = p;
let base = dimension.base();
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`.
fn next_dimension_block_name(doc: &CadDocument) -> String {
let mut n = 0u64;
fn next_dimension_block_name(doc: &CadDocument, next: &mut u64) -> String {
loop {
let n = *next;
*next = (*next).saturating_add(1);
let cand = format!("*D{n}");
if doc.block_records.get(&cand).is_none() {
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
/// original graphics are preserved.
pub fn bake_dimension_blocks(doc: &mut CadDocument) {
// Keep group-10 (base.definition_point) in step with the per-type geometry
// before writing — see sync_dimension_base_points.
sync_dimension_base_points(doc);
// Handles of dimensions whose block reference is missing or dangling.
let pending: Vec<Handle> = doc
.entities()
.filter_map(|e| match e {
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
}
}
// Keep group-10 in step and find missing blocks in one entity pass. Existing
// `*D` blocks are the save cache: only invalidated/new dimensions enter the
// relatively expensive geometry generation below.
let pending = sync_dimension_base_points_and_collect_pending(doc);
let dimensions: Vec<(Handle, Dimension)> = pending
.into_iter()
.filter_map(|handle| match doc.get_entity(handle) {
Some(EntityType::Dimension(d)) => Some((handle, d.clone())),
_ => None,
})
.collect();
for handle in pending {
let dim = match doc.get_entity(handle) {
Some(EntityType::Dimension(d)) => d.clone(),
_ => continue,
};
let subs = explode_dimension(&dim, doc);
use crate::par::prelude::*;
let doc_ref: &CadDocument = doc;
let generated: Vec<(Handle, Vec<EntityType>)> = dimensions
.par_iter()
.map(|(handle, dim)| (*handle, explode_dimension(dim, doc_ref)))
.collect();
let mut next_block_number = 0u64;
for (handle, subs) in generated {
if subs.is_empty() {
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.
// Adding the block + endblk (which carry explicit handles) advances the
// document's handle counter past them, so the NULL-handle sub-entities
+189 -97
View File
@@ -479,6 +479,128 @@ pub struct InteractionHandleIndex {
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 {
pub fn build(entries: impl IntoIterator<Item = (u64, [f64; 6])>) -> Self {
Self {
@@ -554,106 +676,76 @@ impl InteractionIndex {
let mut unbounded_wires = Vec::new();
let mut max_line_half_width_px = 0.0f32;
for (wire_idx, wire) in wires.iter().enumerate() {
if wire.line_weight_px.is_finite() {
max_line_half_width_px =
max_line_half_width_px.max((wire.line_weight_px * 0.5).max(0.0));
}
let wire_idx = wire_idx as u32;
if let Some(aabb) = finite_wire_aabb3(wire) {
wire_entries.push(Entry3 {
aabb,
value: wire_idx,
});
} else {
unbounded_wires.push(wire_idx);
}
#[cfg(not(target_arch = "wasm32"))]
let per_wire: Vec<WireIndexEntries> = {
use crate::par::prelude::*;
wires
.par_iter()
.enumerate()
.map(|(index, wire)| collect_wire_index_entries(index as u32, wire))
.collect()
};
#[cfg(target_arch = "wasm32")]
let per_wire: Vec<WireIndexEntries> = wires
.iter()
.enumerate()
.map(|(index, wire)| collect_wire_index_entries(index as u32, wire))
.collect();
for start in 0..wire.points.len().saturating_sub(1) {
let a = wire_point(wire, start);
let b = wire_point(wire, start + 1);
let Some(aabb) = points_aabb3([a, b]) else {
continue;
};
segment_entries.push(Entry3 {
aabb,
value: SegmentRef {
wire: wire_idx,
start: start as u32,
},
});
// Every per-wire worker knows its exact output sizes. Reserve the flat
// arrays once before draining them so a dense block drawing does not
// repeatedly copy already-flattened entries while the Vecs grow.
wire_entries.reserve(per_wire.iter().filter(|entries| entries.wire.is_some()).count());
unbounded_wires.reserve(per_wire.iter().filter(|entries| entries.unbounded).count());
segment_entries.reserve(per_wire.iter().map(|entries| entries.segments.len()).sum());
snap_point_entries.reserve(
per_wire
.iter()
.map(|entries| entries.snap_points.len())
.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 !point.is_finite() {
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,
},
});
if entries.unbounded {
unbounded_wires.push(wire_idx 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"))]
+25 -5
View File
@@ -149,7 +149,9 @@ pub struct Pipeline {
/// ref-counted, so N paper viewports (or Model tiles) drawing one identical
/// resident set hold one copy of the GPU vertex buffers between them —
/// 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>>,
/// 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
@@ -164,6 +166,10 @@ pub struct Pipeline {
/// The Model content id both arenas currently mirror (`u64::MAX` = none).
#[cfg(not(target_arch = "wasm32"))]
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
/// vertex buffer in the render target's normalized device coords (`None` =
/// rectangular / unclipped, where the viewport's own render rectangle does
@@ -1450,6 +1456,8 @@ impl Pipeline {
wire_arena_mesh: None,
#[cfg(not(target_arch = "wasm32"))]
wire_arena_id: u64::MAX,
#[cfg(not(target_arch = "wasm32"))]
wire_cull_key: (u64::MAX, u64::MAX, 0, 0),
clip_boundary: None,
gpu_selected_wires: vec![],
gpu_preview_wires: vec![],
@@ -2572,7 +2580,10 @@ impl Pipeline {
pass.set_bind_group(1, bg.as_ref(), &[]);
}
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_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
// 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_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_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,
);
}
}
}
+295 -43
View File
@@ -62,6 +62,9 @@ struct Slab {
inst_len: u32,
const_off: 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
/// consts may carry block-local offsets around it; structural edits shift
/// 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)
}
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(
device: &wgpu::Device,
bgl: &wgpu::BindGroupLayout,
@@ -197,22 +231,50 @@ fn make_const_bg(
}))
}
fn alloc_inst(device: &wgpu::Device, cap: u64) -> wgpu::Buffer {
device.create_buffer(&wgpu::BufferDescriptor {
fn alloc_inst_initialized(
device: &wgpu::Device,
cap: u64,
data: &[WireInstance],
) -> wgpu::Buffer {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("wire_arena.ibuf"),
size: cap * std::mem::size_of::<WireInstance>() as u64,
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 {
device.create_buffer(&wgpu::BufferDescriptor {
fn alloc_const_initialized(
device: &wgpu::Device,
cap: u64,
data: &[WireConst],
) -> wgpu::Buffer {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("wire_arena.cbuf"),
size: cap * std::mem::size_of::<WireConst>() as u64,
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 {
@@ -230,8 +292,7 @@ fn blank_instance() -> WireInstance {
distance_a: 0.0,
distance_b: 0.0,
wire_id: 0,
world_hw_a: 0.0,
world_hw_b: 0.0,
taper_ratio: [0; 2],
}
}
@@ -241,44 +302,128 @@ impl WireArena {
/// path).
pub fn build(
device: &wgpu::Device,
queue: &wgpu::Queue,
_queue: &wgpu::Queue,
wires: &[&WireModel],
depth_map: &FxHashMap<u64, [f32; 2]>,
const_bgl: &wgpu::BindGroupLayout,
mesh_edge: bool,
) -> Option<Self> {
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.
let mut instances: Vec<WireInstance> = Vec::new();
let mut consts_cpu: Vec<WireConst> = vec![blank_const()];
let mut slabs: FxHashMap<Handle, Slab> = FxHashMap::default();
for (h, i, j) in ranges {
let mut instances: Vec<WireInstance> = Vec::with_capacity(inst_count);
let mut consts_cpu: Vec<WireConst> = Vec::with_capacity(const_count);
consts_cpu.push(blank_const());
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 const_off = consts_cpu.len() as u32;
let base_depth = if mesh_edge {
0.0
} else {
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);
}
let inst_len = packed_slab.instances.len() as u32;
let const_len = packed_slab.consts.len() as u32;
instances.append(&mut packed_slab.instances);
consts_cpu.append(&mut packed_slab.consts);
slabs.insert(
h,
packed_slab.handle,
Slab {
inst_off,
inst_len: instances.len() as u32 - inst_off,
const_off,
const_len: consts_cpu.len() as u32 - const_off,
base_depth,
inst_len,
const_off: packed_slab.const_off,
const_len,
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)
.max(MIN_CONST_CAP)
.min(MAX_CONSTS)) as u32;
let inst_buf = alloc_inst(device, inst_cap as u64);
let const_buf = alloc_const(device, const_cap as u64);
if inst_tail > 0 {
queue.write_buffer(&inst_buf, 0, bytemuck::cast_slice(&instances));
}
queue.write_buffer(&const_buf, 0, bytemuck::cast_slice(&consts_cpu));
let upload_started = std::time::Instant::now();
let inst_buf = alloc_inst_initialized(device, inst_cap as u64, &instances);
let const_buf = alloc_const_initialized(device, const_cap as u64, &consts_cpu);
let upload_ms = upload_started.elapsed().as_secs_f64() * 1000.0;
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 {
inst_buf,
@@ -381,6 +537,7 @@ impl WireArena {
} else {
depth_map.get(&h.value()).map_or(0.0, |d| d[0])
};
let aabb = run_aabb(run);
if !self.slabs.contains_key(&h)
&& self
@@ -419,7 +576,9 @@ impl WireArena {
const_off as u64 * csz,
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;
}
@@ -465,6 +624,7 @@ impl WireArena {
inst_len,
const_off,
const_len,
aabb,
base_depth,
},
);
@@ -509,9 +669,101 @@ impl WireArena {
}
vec![WireGpu {
instance_buffer: self.inst_buf.clone(),
first_instance: 0,
instance_count: self.inst_tail,
is_3d_mesh_edge: self.mesh_edge,
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()
}
}
+31 -15
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
// 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 60 B (~42 %) 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.
// 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)]
@@ -80,11 +81,11 @@ pub struct WireInstance {
pub distance_b: f32,
/// Index into the per-wire `WireConst` storage buffer (group 1).
pub wire_id: u32,
/// World-space half-width at each endpoint for a TAPERED band. `0.0` =
/// use the per-wire constant (`WireConst.world_half_width`). The shader
/// interpolates between the two so the band tapers across the segment.
pub world_hw_a: f32,
pub world_hw_b: f32,
/// Endpoint width / the per-wire maximum width, normalized by the vertex
/// fetch unit. `[0, 0]` means use the constant width. Ratios retain the
/// full f32 world-width scale in `WireConst` while making every instance
/// exactly one 64-byte cache line.
pub taper_ratio: [u16; 2],
}
#[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_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, world_hw_a) as u64, shader_location: 7, format: wgpu::VertexFormat::Float32 },
wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, world_hw_b) as u64, shader_location: 8, format: wgpu::VertexFormat::Float32 },
wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, taper_ratio) as u64, shader_location: 7, format: wgpu::VertexFormat::Unorm16x2 },
];
wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<WireInstance>() as u64,
@@ -284,6 +284,8 @@ impl WirePipelineMode {
pub struct WireGpu {
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,
/// `true` when the source `WireModel` also carries `fill_tris`
/// (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);
}
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
// per-wire constant width).
let tw = |i: usize| -> f32 { wire.taper_widths.get(i).copied().unwrap_or(0.0) * 0.5 };
// Store an endpoint/max-width ratio. The shared f32 maximum keeps drawing
// units and range out of the packed field; UNORM16 contributes only a
// 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);
for i in 0..seg_count {
let a = wire.points[i];
@@ -538,8 +551,7 @@ pub(crate) fn emit_wire_native(
distance_a: dists[i],
distance_b: dists[i + 1],
wire_id,
world_hw_a: tw(i),
world_hw_b: tw(i + 1),
taper_ratio: [taper_ratio(i), taper_ratio(i + 1)],
});
}
(instances, cst)
@@ -652,6 +664,7 @@ impl WireGpu {
let buf = instance_buffer_mapped(device, "wire.run.ibuf", chunk);
Self {
instance_buffer: buf,
first_instance: 0,
instance_count: chunk.len() as u32,
is_3d_mesh_edge: mesh_edge,
const_bind_group: Some(bg.clone()),
@@ -684,6 +697,7 @@ impl WireGpu {
let buf = instance_buffer_mapped(device, "wire.run.compat.ibuf", chunk);
Self {
instance_buffer: buf,
first_instance: 0,
instance_count: chunk.len() as u32,
is_3d_mesh_edge: mesh_edge,
const_bind_group: None,
@@ -740,6 +754,7 @@ impl WireGpu {
let instance_buffer = instance_buffer_mapped(device, &label, chunk);
Self {
instance_buffer,
first_instance: 0,
instance_count: chunk.len() as u32,
is_3d_mesh_edge: false,
const_bind_group: Some(bg.clone()),
@@ -766,6 +781,7 @@ impl WireGpu {
let instance_buffer = instance_buffer_mapped(device, &label, chunk);
Self {
instance_buffer,
first_instance: 0,
instance_count: chunk.len() as u32,
is_3d_mesh_edge: false,
const_bind_group: None,
+39
View File
@@ -271,6 +271,10 @@ impl shader::Primitive for Primitive {
inner.cached_mesh_source = None;
inner.cached_face3d_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;
}
// 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_wipeout_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 {
inner.viewcube.upload(
queue,
+13 -12
View File
@@ -1,9 +1,9 @@
// 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
// 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
// per-segment re-fetch of constants. WebGL2 has no vertex-stage storage
// buffers, so the wasm build uses wire.wgsl (fat instance) instead.
// instance. Cuts the instance from 104 B to one 64-byte cache line and removes
// the redundant per-segment re-fetch of constants. WebGL2 has no vertex-stage
// storage buffers, so the wasm build uses wire.wgsl (fat instance) instead.
struct Uniforms {
viewport_size: vec2<f32>,
@@ -44,10 +44,9 @@ struct InstanceIn {
@location(4) distance_a: f32,
@location(5) distance_b: f32,
@location(6) wire_id: u32,
// Per-endpoint world half-width for a tapered band (0 = use the per-wire
// constant `world_half_width`).
@location(7) world_hw_a: f32,
@location(8) world_hw_b: f32,
// Per-endpoint width / per-wire maximum width. Vertex UNORM16 conversion
// expands this to 0..1; zero keeps the constant-width fallback.
@location(7) taper_ratio: vec2<f32>,
}
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
// constant world-unit band, then the screen-pixel lineweight (LWDISPLAY off
// collapses to a hairline).
fn resolve_hw(taper: f32, world_hw: f32, px_hw: f32) -> f32 {
if taper > 0.0 { return max(taper / u.world_per_pixel, 0.5); }
fn resolve_hw(taper_ratio: f32, world_hw: f32, px_hw: f32) -> f32 {
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); }
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
// and shrinks with zoom. A normal wire (world_half_width == 0) uses the
// 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
// 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.
let hw_a = resolve_hw(in.world_hw_a, 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_a = resolve_hw(in.taper_ratio.x, 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);
// Extend the quad longitudinally by the end half-width and let the