From 12bf126646e71a16d5baa5c22185700f73847738 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sat, 25 Jul 2026 13:34:48 +0300 Subject: [PATCH] perf: remove large drawing stalls Keep resident geometry and GPU resources stable across edits, MSPACE zoom, and Model/Paper switches. Add unified PERF output for tracing remaining costs. --- Cargo.lock | 2 +- Cargo.toml | 4 +- src/app/commands/fileops.rs | 5 +- src/app/commands/mod.rs | 1 + src/app/history.rs | 6 +- src/app/mod.rs | 7 +- src/app/update/file.rs | 8 +- src/app/update/mod.rs | 39 +++- src/app/update/viewport.rs | 67 +++++- src/app/view/mod.rs | 110 +++++++-- src/io/mod.rs | 8 +- src/lib.rs | 1 + src/main.rs | 1 + src/perf.rs | 62 ++++++ src/scene/camera_ops.rs | 73 ++---- src/scene/convert/tess.rs | 24 +- src/scene/mod.rs | 332 ++++++++++++++++++---------- src/scene/mspace.rs | 22 +- src/scene/paper.rs | 159 ++++++++++--- src/scene/pick/interaction_index.rs | 4 +- src/scene/pipeline/uniforms.rs | 12 +- src/scene/pipeline/wire_arena.rs | 8 +- src/scene/project.rs | 34 +-- src/scene/view/render.rs | 92 ++++++-- src/shaders/wire.wgsl | 20 +- src/shaders/wire_indexed.wgsl | 20 +- 26 files changed, 795 insertions(+), 326 deletions(-) create mode 100644 src/perf.rs diff --git a/Cargo.lock b/Cargo.lock index 55b312891..d80235ada 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -74,7 +74,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.4.0" -source = "git+https://github.com/OpenAEC-Foundation/acadifc?rev=fb4140aa7ed82bb8e35e724985e95debad4d8666#fb4140aa7ed82bb8e35e724985e95debad4d8666" +source = "git+https://github.com/OpenAEC-Foundation/acadifc?rev=cd90c256a5e7d115f6e995275d6b3117d5775da9#cd90c256a5e7d115f6e995275d6b3117d5775da9" dependencies = [ "ahash 0.8.12", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 94d0acb55..a22b95283 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -89,8 +89,8 @@ lyon_tessellation = "1.0.20" 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] -# Track the verified DBCOLOR round-trip fix. -acadrust = { git = "https://github.com/OpenAEC-Foundation/acadifc", rev = "fb4140aa7ed82bb8e35e724985e95debad4d8666" } +# Track the verified DWG round-trip, I/O, and unified PERF fixes. +acadrust = { git = "https://github.com/OpenAEC-Foundation/acadifc", rev = "cd90c256a5e7d115f6e995275d6b3117d5775da9" } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] # Native enables the plugin host runtime (out-of-process plugins). diff --git a/src/app/commands/fileops.rs b/src/app/commands/fileops.rs index 80093ea81..c60fc8207 100644 --- a/src/app/commands/fileops.rs +++ b/src/app/commands/fileops.rs @@ -234,10 +234,11 @@ impl OpenCADStudio { // Toggle the per-rebuild wire-tessellation readout overlay. "PERF" => { self.perf_hud = !self.perf_hud; + crate::perf::set_ui_enabled(self.perf_hud); self.command_line.push_info(if self.perf_hud { - "PERF HUD on — shows last wire re-tessellation cost" + "PERF panel on — tracing render and interaction costs" } else { - "PERF HUD off" + "PERF panel off" }); return Some(Task::none()); } diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 271c03592..a0966a2e7 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -234,6 +234,7 @@ pub fn start_allowed(cmd: &str) -> bool { | "DONATE" | "WEBVERSION" | "HELP" + | "PERF" | "CUI" | "ALIASEDIT" | "CUILOAD" diff --git a/src/app/history.rs b/src/app/history.rs index 8a5a434ae..a521329f2 100644 --- a/src/app/history.rs +++ b/src/app/history.rs @@ -460,9 +460,9 @@ impl OpenCADStudio { .filter(|h| scene.document.get_entity(*h).is_some()) .collect(); scene.selected = restored; - // Do not call set_current_layout here: it bumps geometry immediately. - // Entity deltas currently preserve the layout; direct assignment also - // keeps future widened deltas batchable. + // Entity deltas currently preserve the layout; direct assignment avoids + // clearing layout render caches between steps and keeps future widened + // deltas batchable. scene.current_layout = if undo { d.current_layout_before.clone() } else { diff --git a/src/app/mod.rs b/src/app/mod.rs index c13a5bb1d..661005625 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -305,7 +305,8 @@ pub(super) struct OpenCADStudio { /// press-drag draws a rectangle marquee. pick_drag_rect: bool, /// Frame-budget HUD (Phase 5.3): overlays the last wire re-tessellation - /// cost on the active viewport. Toggled by the `PERF` command. + /// cost and the shared performance trace on the active viewport. Toggled + /// by the `PERF` command. perf_hud: bool, /// When set, the cycling list box is open: (canvas point, candidates). cycle_candidates: Option<(iced::Point, Vec)>, @@ -1501,6 +1502,10 @@ pub enum Message { CommandHistoryCopy, /// Clear every line from the command-line history. CommandHistoryClear, + /// Copy every line currently retained by the PERF panel. + PerfCopy, + /// Clear the PERF panel's retained trace. + PerfClear, /// Text-editor action from the read-only history dropdown. Only /// non-editing actions (cursor moves, selection, scroll) are applied so /// the log stays read-only while remaining drag-selectable and copyable. diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 155950f12..518b85f7b 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -819,8 +819,8 @@ pub(super) fn on_open_file(&mut self) -> Task { 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!( + if crate::perf::enabled() { + crate::perf_record!( "[perf] save-snapshot {:.1}ms entities={} objects={} purpose={purpose:?}", clone_ms, snapshot.entities().count(), @@ -848,8 +848,8 @@ pub(super) fn on_open_file(&mut self) -> Task { png, viewport, ); - if std::env::var_os("OCS_PERF").is_some() { - eprintln!( + if crate::perf::enabled() { + crate::perf_record!( "[perf] save-thumbnail {:.1}ms wires={}", started.elapsed().as_secs_f64() * 1000.0, wires.len(), diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 5d9da2565..59641e671 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -1010,6 +1010,20 @@ impl OpenCADStudio { Task::none() } + Message::PerfCopy => { + let text = crate::perf::snapshot_text(); + if text.is_empty() { + Task::none() + } else { + iced::clipboard::write(text) + } + } + + Message::PerfClear => { + crate::perf::clear(); + Task::none() + } + Message::CommandHistoryEdit(action) => { // Read-only: drop edits, keep selection / cursor / scroll so // the user can still highlight and Ctrl+C the log. @@ -1681,8 +1695,8 @@ impl OpenCADStudio { epoch, source, wires, index, ) }); - if std::env::var_os("OCS_PERF").is_some() { - eprintln!( + if crate::perf::enabled() { + crate::perf_record!( "[perf] interaction-index-bg {:>7.1}ms installed={installed}", build_ms, ); @@ -3729,6 +3743,8 @@ impl OpenCADStudio { Message::EnterViewport(handle) => { let i = self.active_tab; + let perf = crate::perf::enabled(); + let total = Instant::now(); // Clear paper-space selection before entering model space. self.tabs[i].scene.deselect_all(); self.tabs[i].scene.active_viewport = Some(handle); @@ -3736,13 +3752,32 @@ impl OpenCADStudio { // centre so pan/zoom, paper↔model and the display all agree — // otherwise the camera auto-fits to the model while the cursor // math stays at the origin, jittering as pan toggles the two. + let phase = Instant::now(); self.tabs[i].scene.normalize_active_viewport_view(); + let normalize_ms = phase.elapsed().as_secs_f64() * 1000.0; // Grid/snap follow the entered viewport. + let phase = Instant::now(); self.adopt_view_display(i); + let display_ms = phase.elapsed().as_secs_f64() * 1000.0; // Adopt the entered viewport's own per-viewport UCS. + let phase = Instant::now(); self.tabs[i].refresh_active_ucs(); + let ucs_ms = phase.elapsed().as_secs_f64() * 1000.0; + let phase = Instant::now(); self.refresh_properties(); + let properties_ms = phase.elapsed().as_secs_f64() * 1000.0; self.command_line.push_output("MSPACE"); + if perf { + crate::perf_record!( + "[perf] viewport-enter total={:.2}ms normalize={:.2}ms display={:.2}ms ucs={:.2}ms properties={:.2}ms handle={}", + total.elapsed().as_secs_f64() * 1000.0, + normalize_ms, + display_ms, + ucs_ms, + properties_ms, + handle.value(), + ); + } Task::none() } diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index af1f642ae..8d598c499 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -497,7 +497,7 @@ impl OpenCADStudio { return Task::none(); } let i = self.active_tab; - let perf_move = std::env::var_os("OCS_PERF").is_some(); + let perf_move = crate::perf::enabled(); let move_started = Instant::now(); // UCS icon grip drag: map the cursor onto the UCS plane and @@ -606,6 +606,9 @@ impl OpenCADStudio { drop(sel); self.tabs[i].scene.orbit_active_viewport(dx, dy); self.tabs[i].scene.camera_generation += 1; + self.tabs[i] + .scene + .record_nav_perf(crate::scene::NavPerfOp::Rotate, move_started); self.tabs[i].scene.selection.borrow_mut().middle_last_pos = Some(p); return Task::none(); } else if self.tabs[i].scene.current_layout == "Model" { @@ -621,6 +624,9 @@ impl OpenCADStudio { drop(sel); self.tabs[i].scene.camera.borrow_mut().orbit(dx, dy, pivot); self.tabs[i].scene.camera_generation += 1; + self.tabs[i] + .scene + .record_nav_perf(crate::scene::NavPerfOp::Rotate, move_started); self.tabs[i].scene.selection.borrow_mut().middle_last_pos = Some(p); return Task::none(); } @@ -654,6 +660,9 @@ impl OpenCADStudio { // drawing as the view pans under it (#234). self.reproject_box_anchor(i, vp_size.0, vp_size.1); } + self.tabs[i] + .scene + .record_nav_perf(crate::scene::NavPerfOp::Pan, move_started); self.tabs[i].scene.selection.borrow_mut().middle_last_pos = Some(p); return Task::none(); } @@ -886,7 +895,7 @@ impl OpenCADStudio { // value and can monopolize the UI thread during a drag. let total_ms = grip_started.elapsed().as_secs_f64() * 1000.0; if perf_move && total_ms >= 50.0 { - eprintln!( + crate::perf_record!( "[perf] grip-move {:>7.1}ms setup={:.1} snap={:.1} apply={:.1} preview={:.1} grips={:.1}", total_ms, setup_ms, @@ -1453,9 +1462,18 @@ impl OpenCADStudio { self.sync_dyn_fields(); let move_ms = move_started.elapsed().as_secs_f64() * 1000.0; - if perf_move && self.tabs[i].active_cmd.is_some() && move_ms >= 50.0 { - eprintln!( - "[perf] pointer-move {:>7.1}ms", + if perf_move + && (self.tabs[i].active_cmd.is_some() + || self.tabs[i].scene.active_viewport.is_some()) + && move_ms >= 16.7 + { + let mode = if self.tabs[i].active_cmd.is_some() { + "command" + } else { + "MSPACE" + }; + crate::perf_record!( + "[perf] pointer-move mode={mode:<7} {:>7.1}ms", move_ms, ); } @@ -3250,12 +3268,16 @@ impl OpenCADStudio { } if is_double { self.tabs[i].scene.fit_all(); + self.tabs[i] + .scene + .record_nav_perf(crate::scene::NavPerfOp::Zoom, now); self.command_line.push_output("Zoom Extents"); } Task::none() } pub(super) fn on_viewport_scroll(&mut self, delta: mouse::ScrollDelta) -> Task { + let nav_started = Instant::now(); let s = match delta { mouse::ScrollDelta::Lines { y, .. } => y, mouse::ScrollDelta::Pixels { y, .. } => y * 0.01, @@ -3310,6 +3332,9 @@ impl OpenCADStudio { // as the view zooms under it (#234). self.reproject_box_anchor(i, vw, vh); } + self.tabs[i] + .scene + .record_nav_perf(crate::scene::NavPerfOp::Zoom, nav_started); Task::none() } @@ -3460,7 +3485,7 @@ impl OpenCADStudio { { return Task::none(); } - let perf = std::env::var_os("OCS_PERF").is_some(); + let perf = crate::perf::enabled(); let hover_started = Instant::now(); let i = dwell.tab; // Re-check the gate — drag / command may have started @@ -3564,10 +3589,10 @@ impl OpenCADStudio { self.hover_dwell = None; let hover_ms = hover_started.elapsed().as_secs_f64() * 1000.0; if perf && hover_ms >= 5.0 { - eprintln!( + crate::perf_record!( "[perf] hover-detail query={candidate_ms:.1} handles={handles_ms:.1} wire={wire_ms:.1} hatch={hatch_ms:.1} insert={insert_ms:.1} solid={solid_ms:.1} candidates={candidate_count}", ); - eprintln!( + crate::perf_record!( "[perf] hover-dwell {:>7.1}ms wires={} hit={}", hover_ms, self.tabs[i].scene.last_tess_wires.get(), @@ -3642,21 +3667,29 @@ impl OpenCADStudio { ); return Task::none(); } + let perf = crate::perf::enabled(); + let perf_total = Instant::now(); + let perf_from = self.tabs[i].scene.current_layout.clone(); let going_to_paper = name != "Model"; // Persist the camera of the layout we're leaving BEFORE switching // so returning to it restores where the user left off (the // periodic sync only fires on a tick, which may not have run // since the last pan/zoom). + let perf_phase = Instant::now(); self.tabs[i].scene.sync_camera_to_document(); self.tabs[i].last_synced_camera_gen = self.tabs[i].scene.camera_generation; + let sync_ms = perf_phase.elapsed().as_secs_f64() * 1000.0; // Cancel any pending rename/context-menu and active viewport when switching. self.layout_rename_state = None; self.layout_context_menu = None; self.tabs[i].scene.active_viewport = None; - self.tabs[i].scene.set_current_layout(name); + let perf_phase = Instant::now(); + self.tabs[i].scene.set_current_layout(name.clone()); self.tabs[i].scene.deselect_all(); + let switch_ms = perf_phase.elapsed().as_secs_f64() * 1000.0; // UCS follows the pane: model header UCS in the Model tab, none // in plain paper space (a viewport's UCS is adopted on entry). + let perf_phase = Instant::now(); self.tabs[i].refresh_active_ucs(); self.tabs[i].scene.restore_saved_camera(); self.tabs[i].last_synced_camera_gen = self.tabs[i].scene.camera_generation; @@ -3664,15 +3697,31 @@ impl OpenCADStudio { // sheet viewport in paper space, the model tile in model space) // so model and each layout keep independent grid state. self.adopt_view_display(i); + let restore_ms = perf_phase.elapsed().as_secs_f64() * 1000.0; // Paper-space tools live in the right-edge side toolbar now, so // entering/leaving a layout no longer hijacks the ribbon tab. let _ = going_to_paper; // Refresh VP freeze columns for the new layout. + let perf_phase = Instant::now(); let doc_layers = self.tabs[i].scene.document.layers.clone(); let vp_info = self.tabs[i].scene.viewport_list(); self.tabs[i] .layers .sync_with_viewports(&doc_layers, vp_info); + let layers_ms = perf_phase.elapsed().as_secs_f64() * 1000.0; + if perf { + crate::perf_record!( + "[perf] layout-switch from={} to={} total={:.2}ms sync={:.2}ms switch={:.2}ms restore={:.2}ms layers={:.2}ms epoch={}", + perf_from, + name, + perf_total.elapsed().as_secs_f64() * 1000.0, + sync_ms, + switch_ms, + restore_ms, + layers_ms, + self.tabs[i].scene.geometry_epoch, + ); + } Task::none() } diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 31c4b9cbb..cf7b7594a 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -9,8 +9,8 @@ use crate::scene::{VIEWCUBE_PAD, VIEWCUBE_REGION_PX}; use crate::ui::wrap_bar::DensitySwap; use crate::ui::wrap_bar::WrapFlow; use iced::widget::{ - button, canvas, column, container, mouse_area, pane_grid, responsive, row, shader, stack, text, - Row, Space, + button, canvas, column, container, mouse_area, pane_grid, responsive, row, scrollable, shader, + stack, text, Row, Space, }; use iced::window; use iced::{keyboard, Background, Border, Color, Element, Fill, Subscription, Task, Theme}; @@ -1121,32 +1121,44 @@ impl OpenCADStudio { } } - // Frame-budget HUD (Phase 5.3): toggle with the PERF command. Shows - // the cost of the most recent wire re-tessellation — the work avoided - // by a warm wire cache — so render-path changes can be compared - // PR-to-PR. Reads ~0 ms while panning/zooming on a hit cache. - if self.perf_hud && !tab.is_start { + // Shared performance panel: terminal PERF lines plus the current + // tessellation summary. Copy / Clear mirror the command-history panel. + if self.perf_hud { let s = &tab.scene; - let label = format!( + let perf_w = if render_bar_w.is_finite() && render_bar_w > 1.0 { + render_bar_w + } else { + 320.0 + }; + let summary = format!( "tess {:.1} ms · {} wires · epoch {}", s.last_tess_ms.get(), s.last_tess_wires.get(), s.geometry_epoch, ); - let panel = container(text(label).size(12).color(Color { - r: 0.6, - g: 1.0, - b: 0.6, - a: 1.0, - })) - .padding(6) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(Color { - r: 0.08, - g: 0.08, - b: 0.08, - a: 0.85, + let trace = crate::perf::snapshot_text(); + let trace = if trace.is_empty() { + "No samples yet".to_string() + } else { + trace + }; + let perf_button_style = |_: &Theme, status: button::Status| button::Style { + background: Some(Background::Color(if matches!(status, button::Status::Hovered) { + Color { + r: 0.24, + g: 0.24, + b: 0.24, + a: 1.0, + } + } else { + Color { + r: 0.14, + g: 0.14, + b: 0.14, + a: 1.0, + } })), + text_color: Color::WHITE, border: Border { color: Color { r: 0.35, @@ -1158,6 +1170,62 @@ impl OpenCADStudio { radius: 3.0.into(), }, ..Default::default() + }; + let copy_btn = button( + row![ + crate::ui::icons::tinted( + crate::ui::icons::COPY, + 11.0, + Color::from_rgb(0.7, 0.85, 1.0), + ), + text("Copy").size(11), + ] + .spacing(4) + .align_y(iced::Center), + ) + .on_press(Message::PerfCopy) + .style(perf_button_style) + .padding([2, 6]); + let clear_btn = button( + row![ + crate::ui::icons::tinted( + crate::ui::icons::TRASH, + 11.0, + Color::from_rgb(1.0, 0.55, 0.55), + ), + text("Clear").size(11), + ] + .spacing(4) + .align_y(iced::Center), + ) + .on_press(Message::PerfClear) + .style(perf_button_style) + .padding([2, 6]); + let header = row![ + text("PERF").size(12).color(Color::from_rgb(0.6, 1.0, 0.6)), + Space::new().width(iced::Length::Fill), + copy_btn, + clear_btn, + ] + .spacing(6) + .align_y(iced::Center); + let log = scrollable(text(trace).size(11).color(Color::from_rgb(0.8, 0.9, 0.8))) + .height(iced::Length::Fixed(220.0)) + .width(iced::Length::Fill); + let panel = container( + column![ + header, + text(summary).size(11).color(Color::from_rgb(0.6, 1.0, 0.6)), + log, + ] + .spacing(5), + ) + .width(iced::Length::Fixed(perf_w)) + .padding(6) + .style(|_: &Theme| container::Style { + background: None, + border: Border::default(), + ..Default::default() }); viewport_stack = viewport_stack.push(position_canvas_overlay( iced::Point::new(12.0, 40.0), diff --git a/src/io/mod.rs b/src/io/mod.rs index be8771e6d..2bb1f5702 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -451,7 +451,7 @@ fn save_owned_as_version_inner( backup: bool, clone_ms: f64, ) -> Result<(), String> { - let perf = std::env::var_os("OCS_PERF").is_some(); + let perf = crate::perf::enabled(); let total_started = std::time::Instant::now(); doc.version = version; let styles_started = std::time::Instant::now(); @@ -484,7 +484,7 @@ fn save_owned_as_version_inner( return Err(format!("replace {}: {error}", path.display())); } if perf { - eprintln!( + crate::perf_record!( "[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, @@ -567,7 +567,7 @@ pub fn save_to_bytes( ext: &str, version: acadrust::DxfVersion, ) -> Result, String> { - let perf = std::env::var_os("OCS_PERF").is_some(); + let perf = crate::perf::enabled(); let total_started = std::time::Instant::now(); let clone_started = std::time::Instant::now(); let mut doc = doc.clone(); @@ -590,7 +590,7 @@ pub fn save_to_bytes( }; if perf { let bytes = result.as_ref().map_or(0, Vec::len); - eprintln!( + crate::perf_record!( "[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, diff --git a/src/lib.rs b/src/lib.rs index b333fdb12..64369e432 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,6 +11,7 @@ pub mod modules; pub mod patreon; pub mod videos; pub mod plugin; +pub mod perf; pub mod scene; pub mod snap; pub mod ui; diff --git a/src/main.rs b/src/main.rs index 6738a2ecc..783e5109b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,6 +15,7 @@ mod modules; mod patreon; mod videos; mod plugin; +mod perf; mod scene; mod snap; mod ui; diff --git a/src/perf.rs b/src/perf.rs new file mode 100644 index 000000000..0f78b5f7b --- /dev/null +++ b/src/perf.rs @@ -0,0 +1,62 @@ +//! Shared performance trace for terminal output and the in-app `PERF` panel. + +use std::collections::VecDeque; +use std::fmt; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Mutex, OnceLock}; + +const MAX_LINES: usize = 500; + +static UI_ENABLED: AtomicBool = AtomicBool::new(false); +static ENV_ENABLED: OnceLock = OnceLock::new(); +static LINES: OnceLock>> = OnceLock::new(); + +fn lines() -> &'static Mutex> { + LINES.get_or_init(|| Mutex::new(VecDeque::with_capacity(MAX_LINES))) +} + +/// True when tracing was requested with `PERF=1` or the in-app panel is open. +pub fn enabled() -> bool { + UI_ENABLED.load(Ordering::Relaxed) + || *ENV_ENABLED.get_or_init(|| std::env::var_os("PERF").is_some()) +} + +/// Enable or disable collection driven by the in-app `PERF` command. +pub fn set_ui_enabled(enabled: bool) { + UI_ENABLED.store(enabled, Ordering::Relaxed); +} + +/// Write one performance line to stderr and retain it for the in-app panel. +pub fn record(args: fmt::Arguments<'_>) { + if !enabled() { + return; + } + let line = args.to_string(); + eprintln!("{line}"); + let mut entries = lines().lock().unwrap_or_else(|e| e.into_inner()); + if entries.len() == MAX_LINES { + entries.pop_front(); + } + entries.push_back(line); +} + +/// Plain-text snapshot used by the panel and its Copy button. +pub fn snapshot_text() -> String { + let entries = lines().lock().unwrap_or_else(|e| e.into_inner()); + entries + .iter() + .map(String::as_str) + .collect::>() + .join("\n") +} + +pub fn clear() { + lines().lock().unwrap_or_else(|e| e.into_inner()).clear(); +} + +#[macro_export] +macro_rules! perf_record { + ($($arg:tt)*) => { + $crate::perf::record(format_args!($($arg)*)) + }; +} diff --git a/src/scene/camera_ops.rs b/src/scene/camera_ops.rs index f654ca791..7abeb762d 100644 --- a/src/scene/camera_ops.rs +++ b/src/scene/camera_ops.rs @@ -487,24 +487,14 @@ impl Scene { /// `OpenCADStudio_Camera_` named View is honoured only as a /// backward-compatible fallback for files saved under the previous scheme. fn apply_sheet_viewport_camera(&mut self) -> bool { - let layout_block = self.current_layout_block_handle(); - let sheet_vp = if layout_block.is_null() { - None - } else { - self.document - .entities() - .filter_map(|e| { - if let EntityType::Viewport(vp) = e { - Some(vp) - } else { - None - } - }) - .find(|vp| { - vp.common.owner_handle == layout_block - && !self.is_content_viewport_in_layout(vp, layout_block) - }) - .cloned() + // The Layout object already owns the authoritative sheet-viewport + // handle. Do not scan every drawing entity on each Model↔Paper switch. + let sheet_vp = match self + .document + .get_entity(self.current_layout_sheet_viewport_handle()) + { + Some(EntityType::Viewport(vp)) => Some(vp.clone()), + _ => None, }; let vp = match sheet_vp { @@ -590,38 +580,21 @@ impl Scene { // The sheet viewport entity is the authoritative paper-space view; // it round-trips natively, so no named-View side-channel is needed. - let layout_block = self.current_layout_block_handle(); - if !layout_block.is_null() { - let sheet_handle = self - .document - .entities() - .filter_map(|e| { - if let EntityType::Viewport(vp) = e { - Some(vp) - } else { - None - } - }) - .find(|vp| { - vp.common.owner_handle == layout_block && !self.is_content_viewport_in_layout(vp, layout_block) - }) - .map(|vp| vp.common.handle); - - if let Some(handle) = sheet_handle { - if let Some(EntityType::Viewport(vp)) = self.document.get_entity_mut(handle) { - // AutoCAD stores the paper-space view position in - // `view_center` (DCS) with `view_target` at the origin — - // writing it the other way round shifts the layout and - // crashes nothing but renders the sheet off-place. Paper - // space is always a plan view, so DCS == WCS XY here. - vp.view_center = - acadrust::types::Vector3::new(target_wcs.x, target_wcs.y, 0.0); - vp.view_target = acadrust::types::Vector3::ZERO; - vp.view_direction = vd3; - vp.view_height = view_height as f64; - vp.twist_angle = twist; - } - } + let sheet_handle = self.current_layout_sheet_viewport_handle(); + if let Some(EntityType::Viewport(vp)) = + self.document.get_entity_mut(sheet_handle) + { + // AutoCAD stores the paper-space view position in + // `view_center` (DCS) with `view_target` at the origin — + // writing it the other way round shifts the layout and + // crashes nothing but renders the sheet off-place. Paper + // space is always a plan view, so DCS == WCS XY here. + vp.view_center = + acadrust::types::Vector3::new(target_wcs.x, target_wcs.y, 0.0); + vp.view_target = acadrust::types::Vector3::ZERO; + vp.view_direction = vd3; + vp.view_height = view_height as f64; + vp.twist_angle = twist; } true } diff --git a/src/scene/convert/tess.rs b/src/scene/convert/tess.rs index 35e08d734..cc667ff8a 100644 --- a/src/scene/convert/tess.rs +++ b/src/scene/convert/tess.rs @@ -352,10 +352,9 @@ pub(crate) fn tessellate_entity( view_aabb: Option<[f32; 4]>, // World units per screen pixel for LOD culling. `None` = no LOD. world_per_pixel: Option, - // True only when tessellating content shown INSIDE a paper-space viewport, - // where PSLTSCALE scales linetypes by the viewport scale. Model-space (and - // paper-sheet) rendering passes false: a drawing's annotation scale must - // not resize model-space linetypes (that is MSLTSCALE, off here). + // True only when tessellating content shown inside a paper-space viewport. + // Retained through recursive INSERT expansion; PSLTSCALE itself is applied + // by the viewport's GPU uniform so it never changes resident wire content. paper_space: bool, ) -> Vec { let h = e.common().handle; @@ -507,19 +506,10 @@ pub(crate) fn tessellate_entity( let entity_color = fade_if_locked(document, e, entity_color, bg_color); let lt_scale = document.header.linetype_scale as f32 * e.common().linetype_scale as f32; let lt_name = view::render::linetype_name_for(document, e); - // PSLTSCALE: scale linetype dashes by the viewport scale so they appear - // uniform in paper space. Only applies to content shown inside a paper-space - // viewport (`paper_space`); model space uses LTSCALE × CELTSCALE unscaled by - // the annotation scale, otherwise a drawing at e.g. CANNOSCALE 10:1 draws - // its linetypes 10× off. - let pslt_factor = if paper_space && document.header.paper_space_linetype_scaling { - anno_scale - } else { - 1.0 - }; - let pattern_length = pattern_length * pslt_factor; - let pattern = pattern.map(|v| v * pslt_factor); - + // Paper-space linetype scaling belongs to the viewport uniform. Keeping + // resident geometry at its model-space scale prevents every MSPACE wheel + // tick from rebuilding and uploading the viewport's complete wire set. + let pslt_factor = 1.0_f32; // ── Proxy entity: draw its cached preview ─────────────────────────────── // // An entity from an application we have no reader for (e.g. an Autodesk diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 2dac4e645..8d7619588 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -265,6 +265,24 @@ struct DrawDepthCache { owners: HashMap, } +struct PaperViewportCache { + epoch: u64, + layout: String, + layout_block: Handle, + sheet: Handle, + content: Arc>, +} + +struct PaperSheetRenderCache { + epoch: u64, + layout: String, + selected: u64, + paper_bg: [f32; 4], + hatches: Arc>, + wipeouts: Arc>, + images: Arc>, +} + /// Bound on the geometry-delta ring. A consumer that fell more than this many /// mutations behind (or predates the oldest retained delta) can't be replayed /// and does a one-time full rebuild — the safe fallback, not a correctness hole. @@ -924,6 +942,33 @@ fn transform_block_mesh_lod_set( out } +#[derive(Clone, Copy, Debug)] +pub(crate) enum NavPerfOp { + Pan, + Zoom, + Rotate, +} + +impl NavPerfOp { + pub(in crate::scene) fn label(self) -> &'static str { + match self { + Self::Pan => "pan", + Self::Zoom => "zoom", + Self::Rotate => "rotate", + } + } +} + +#[derive(Clone, Copy, Debug)] +pub(in crate::scene) struct NavPerfSample { + pub(in crate::scene) op: NavPerfOp, + pub(in crate::scene) space: &'static str, + pub(in crate::scene) mode: &'static str, + pub(in crate::scene) started: iced::time::Instant, + pub(in crate::scene) input_ms: f64, + pub(in crate::scene) build_ms: f64, +} + pub struct Scene { pub camera: Rc>, /// Model-space tiled viewport layout. One full-window tile by default; @@ -1091,14 +1136,14 @@ pub struct Scene { /// Keyed by `(geometry_epoch, selection_generation)` — selected hatches /// are tinted, so a select/deselect must rebuild even when the geometry /// is unchanged (issue #71). - hatch_cache: RefCell>)>>, + hatch_cache: RefCell>)>>, /// Cached wipeout fill models, keyed by geometry_epoch. Same /// reasoning as `hatch_cache`. - wipeout_cache: RefCell>)>>, + wipeout_cache: RefCell>)>>, /// Cached image models, keyed by geometry_epoch. No camera key needed here. image_cache: RefCell>)>>, /// Cached mesh models, keyed by geometry_epoch. - mesh_cache: RefCell>)>>, + mesh_cache: RefCell>)>>, /// Picking mesh source for a non-model active space, keyed by geometry epoch /// and interaction block. Model/MSPACE reuse `mesh_cache` directly. interaction_mesh_cache: RefCell>)>>, @@ -1120,10 +1165,13 @@ pub struct Scene { /// filtered variants. Viewports sharing a frozen set share one entry (like /// the resident wire set). Empty for a viewport with no frozen layers (it /// reuses the unfiltered `*_arc` sets directly). - frozen_hatch_cache: RefCell>)>>, - frozen_wipeout_cache: RefCell>)>>, + frozen_hatch_cache: + RefCell>)>>, + frozen_wipeout_cache: + RefCell>)>>, frozen_image_cache: RefCell>)>>, - frozen_mesh_cache: RefCell>)>>, + frozen_mesh_cache: + RefCell>)>>, /// Cached block-INSERT hatches for hit-testing, keyed by geometry_epoch. /// Building this explodes every model-space INSERT, so without the cache a /// heavy block-instanced drawing re-explodes thousands of inserts on every @@ -1132,9 +1180,18 @@ pub struct Scene { insert_hatch_cache: RefCell>>)>>, /// Sheet "dressing" cache over the unified resident set: the paper sheet /// drops its own border wire and appends the printable-area guide. - /// `(geometry_epoch, content gen, wires)` — the base is camera-independent - /// (no cull / LOD anywhere), so paper pan/zoom never re-tessellates it. - paper_sheet_cache: RefCell>)>>, + /// Per-layout `(geometry_epoch, content gen, wires)` — the base is + /// camera-independent, so paper pan/zoom and Model↔Paper tab switches keep + /// every already-visited sheet warm. + paper_sheet_cache: RefCell>)>>, + /// Layout viewport handles, collected from the owning block record once per + /// geometry epoch. Avoids walking every document entity on every Paper + /// pan/zoom frame just to rediscover the same handful of viewports. + paper_viewport_cache: RefCell>, + /// Stable Paper sheet fill/image sources. Without this cache every camera + /// movement scanned the whole document for wipeouts and recreated the same + /// Arcs, making Paper frame construction CPU-bound on large drawings. + paper_sheet_render_cache: RefCell>, /// Per-viewport projected wire cache for paper-space content viewports. /// Stores projected + clipped wires in paper-space coordinates. /// Maps vp_handle → (geometry_epoch, Vec). @@ -1191,12 +1248,9 @@ pub struct Scene { /// Multiplier applied to Text/MText/Dimension sizes during tessellation. /// 1.0 = no scaling. 50.0 = "1:50" drawing scale. pub annotation_scale: f32, - /// Cached per-epoch: does annotation/viewport scale actually change wire - /// output? True iff PSLTSCALE is on (linetype dashes scale with the - /// viewport) or the drawing has an annotative entity (text/dim/mleader - /// sizing). When false the per-viewport anno scale is inert, so every - /// content viewport collapses onto ONE shared resident set instead of a - /// full re-tessellation per distinct viewport scale. `(epoch, flag)`. + /// Cached per-epoch: does annotation scale actually change wire geometry? + /// PSLTSCALE is handled by a per-viewport GPU uniform, so only real + /// annotative entities require a scale-specific resident set. annotation_affects_wires: std::cell::Cell>, /// Cached model-space bounding box, keyed by geometry_epoch. /// Avoids re-tessellating all entities on every ZOOM E / auto-fit call. @@ -1204,10 +1258,12 @@ pub struct Scene { /// Reverse map: entity_handle → block_record_handle, built from entity_handles lists. /// Keyed by geometry_epoch. Eliminates the O(B) fallback scan in belongs_to_visible_block. entity_block_map_cache: RefCell)>>, - /// Tessellated block definitions in block-local coords, keyed by geometry_epoch. - /// Lets Insert tessellation transform-copy cached wires instead of - /// clone+explode+re-tessellate per reference. - block_defn_cache: RefCell)>>, + /// Tessellated block definitions in block-local coords, keyed by render + /// background and block epoch. Model and Paper adapt black/white colours + /// differently; retaining both variants prevents a full block rebuild on + /// every layout-tab switch. + block_defn_cache: + RefCell)>>, /// Spatial index + always-emit list for top-level entities /// (Phase 2.1). Lazily rebuilt by `entity_index()` on /// `geometry_epoch` change. See `EntityIndex` for what each side @@ -1246,6 +1302,9 @@ pub struct Scene { /// scene-render cache). See [`Scene::navigating_lod`]. nav_last_gen: std::cell::Cell, nav_changed_at: std::cell::Cell>, + /// Latest pan / zoom / rotate event waiting for the renderer. Present only + /// while PERF tracing is enabled; consumed by the active shader primitive. + nav_perf_pending: std::cell::Cell>, /// Unified static-hold wire cache — ONE infrastructure for EVERY space /// (Model tiles, BEDIT block editor, the paper sheet block, and paper /// content viewports): the FULL, un-culled, LOD-free tessellation per @@ -1364,10 +1423,10 @@ impl Scene { interaction_handle_index_cache: RefCell::new(None), sort_cache: RefCell::new(None), draw_depth_cache: RefCell::new(None), - hatch_cache: RefCell::new(None), - wipeout_cache: RefCell::new(None), + hatch_cache: RefCell::new(HashMap::default()), + wipeout_cache: RefCell::new(HashMap::default()), image_cache: RefCell::new(None), - mesh_cache: RefCell::new(None), + mesh_cache: RefCell::new(HashMap::default()), interaction_mesh_cache: RefCell::new(None), mesh_pick_lookup_cache: RefCell::new(None), frozen_hatch_cache: RefCell::new(HashMap::default()), @@ -1375,7 +1434,9 @@ impl Scene { frozen_image_cache: RefCell::new(HashMap::default()), frozen_mesh_cache: RefCell::new(HashMap::default()), insert_hatch_cache: RefCell::new(None), - paper_sheet_cache: RefCell::new(None), + paper_sheet_cache: RefCell::new(HashMap::default()), + paper_viewport_cache: RefCell::new(HashMap::default()), + paper_sheet_render_cache: RefCell::new(HashMap::default()), paper_projected_cache: RefCell::new(HashMap::default()), current_layout: "Model".to_string(), block_edit_block: None, @@ -1394,7 +1455,7 @@ impl Scene { annotation_affects_wires: std::cell::Cell::new(None), model_extents_cache: RefCell::new(None), entity_block_map_cache: RefCell::new(None), - block_defn_cache: RefCell::new(None), + block_defn_cache: RefCell::new(HashMap::default()), entity_index_cache: RefCell::new(None), last_render_aspect: std::cell::Cell::new(16.0 / 9.0), last_world_per_pixel: std::cell::Cell::new(0.0), @@ -1404,6 +1465,7 @@ impl Scene { last_model_wire_gen: std::cell::Cell::new(0), nav_last_gen: std::cell::Cell::new(0), nav_changed_at: std::cell::Cell::new(None), + nav_perf_pending: std::cell::Cell::new(None), resident_wire_sets: RefCell::new(HashMap::default()), split_cache: RefCell::new(HashMap::default()), tess_memo: RefCell::new(HashMap::default()), @@ -1465,19 +1527,20 @@ impl Scene { /// Built single-threaded — recursive nested expansion makes parallelization /// fiddly and the cache only rebuilds when geometry actually changes. pub(super) fn block_cache_arc(&self) -> Arc { - { - let cache = self.block_defn_cache.borrow(); - if let Some((epoch, ref arc)) = *cache { - if epoch == self.block_epoch { - return Arc::clone(arc); - } - } - } let bg = if self.current_layout == "Model" { self.bg_color } else { self.paper_bg_color }; + let key = bg.map(f32::to_bits); + { + let cache = self.block_defn_cache.borrow(); + if let Some((epoch, arc)) = cache.get(&key) { + if *epoch == self.block_epoch { + return Arc::clone(arc); + } + } + } // Block definitions are cached at block-local size (annotation scale // 1.0). An annotative block scales as ONE unit at the INSERT level, so // its internal geometry / text / attributes must NOT be scaled @@ -1486,7 +1549,9 @@ impl Scene { let built = cache::block_cache::BlockCache::build(&self.document, 1.0, bg, &self.draw_depth_map()); let arc = Arc::new(built); - *self.block_defn_cache.borrow_mut() = Some((self.block_epoch, Arc::clone(&arc))); + let mut cache = self.block_defn_cache.borrow_mut(); + cache.retain(|_, (epoch, _)| *epoch == self.block_epoch); + cache.insert(key, (self.block_epoch, Arc::clone(&arc))); arc } @@ -1803,6 +1868,31 @@ impl Scene { .map_or(false, |t| t.elapsed().as_millis() < Self::NAV_SETTLE_MS) } + pub(crate) fn record_nav_perf(&self, op: NavPerfOp, started: iced::time::Instant) { + if !crate::perf::enabled() { + return; + } + let (space, mode) = if self.current_layout == "Model" { + ("Model", "MODEL") + } else if self.active_viewport.is_some() { + ("Paper", "MSPACE") + } else { + ("Paper", "PSPACE") + }; + self.nav_perf_pending.set(Some(NavPerfSample { + op, + space, + mode, + started, + input_ms: started.elapsed().as_secs_f64() * 1000.0, + build_ms: 0.0, + })); + } + + pub(in crate::scene) fn take_nav_perf(&self) -> Option { + self.nav_perf_pending.take() + } + /// Whether the interaction-LOD hatch suppression is enabled (env /// `OCS_HATCH_LOD`), read once. Default OFF: the tessellated hatch pass is /// cheap enough that suppression — and its zoom flicker (#258) — is not @@ -1939,17 +2029,20 @@ impl Scene { } } - /// Switch the active layout. Bumps `geometry_epoch` so the wire cache - /// re-tessellates — `render_style`'s `adapt_to_bg` picks the model or - /// paper background depending on `current_layout`, so cached wires - /// from the previous layout would be coloured against the wrong bg. - /// Also runs `recolor_meshes` so ACIS mesh colour tracks the new bg. + /// Switch the active layout without pretending the document geometry + /// changed. Resident wire sets already key on block/background, so keeping + /// `geometry_epoch` stable lets Model and Paper retain their CPU/GPU data + /// across tab switches. Only caches whose contents genuinely depend on the + /// active layout or its background are dropped. pub fn set_current_layout(&mut self, name: String) { if self.current_layout != name { self.current_layout = name; self.sync_active_space_to_document(); self.recolor_meshes(); - self.bump_geometry(); + *self.wire_cache.borrow_mut() = None; + *self.interaction_mesh_cache.borrow_mut() = None; + *self.mesh_pick_lookup_cache.borrow_mut() = None; + *self.insert_hatch_cache.borrow_mut() = None; } } @@ -2034,6 +2127,17 @@ impl Scene { return; } + // Normal files carry a valid direct Layout→Viewport link. This O(1) + // path is hit on every ordinary layout-tab switch. + if cur_vp.is_valid() + && matches!( + self.document.get_entity(cur_vp), + Some(EntityType::Viewport(vp)) if vp.common.owner_handle == block_record + ) + { + return; + } + // Already present? Accept either the linked viewport handle or any // `id == 1` viewport owned by the layout block. let has_sheet = self.document.entities().any(|e| { @@ -2781,23 +2885,17 @@ impl Scene { if self.current_layout == "Model" { return vec![]; } - let layout_block = self.current_layout_block_handle(); + let (layout_block, _, content) = self.paper_viewport_handles(); if layout_block.is_null() { return vec![]; } - let mut result: Vec<(acadrust::Handle, String, Vec)> = self - .document - .entities() - .filter_map(|e| { - if let EntityType::Viewport(vp) = e { - if self.is_content_viewport_in_layout(vp, layout_block) { - Some((vp.common.handle, vp.id, vp.frozen_layers.clone())) - } else { - None - } - } else { - None - } + let mut result: Vec<(acadrust::Handle, String, Vec)> = content + .iter() + .filter_map(|handle| { + let Some(EntityType::Viewport(vp)) = self.document.get_entity(*handle) else { + return None; + }; + Some((vp.common.handle, vp.id, vp.frozen_layers.clone())) }) .collect::>() .into_iter() @@ -2820,20 +2918,11 @@ impl Scene { if self.current_layout == "Model" { return 0; } - let layout_block = self.current_layout_block_handle(); + let (layout_block, _, content) = self.paper_viewport_handles(); if layout_block.is_null() { return 0; } - self.document - .entities() - .filter(|e| { - if let EntityType::Viewport(vp) = e { - self.is_content_viewport_in_layout(vp, layout_block) - } else { - false - } - }) - .count() + content.len() } /// True when any entities are hidden by Isolate / Hide. @@ -3105,10 +3194,9 @@ impl Scene { /// [`WIRE_CONTENT_GEN`] id per build (relayed via `last_model_wire_gen`) /// so the GPU upload gate and `render_signature` skip unchanged content. /// Whether the per-viewport annotation scale changes wire output at all - /// (PSLTSCALE on, or any annotative entity). Cached per geometry epoch — - /// the scan short-circuits on the first annotative entity. When false, a - /// viewport's `1/vp_scale` anno override is inert and normalized away so - /// all viewports reuse ONE resident tessellation. + /// Cached per geometry epoch; the scan short-circuits on the first + /// annotative entity. PSLTSCALE no longer participates because dash scaling + /// is applied in the wire shader from the viewport uniform. fn annotation_affects_wires(&self) -> bool { if let Some((epoch, v)) = self.annotation_affects_wires.get() { if epoch == self.geometry_epoch { @@ -3128,11 +3216,10 @@ impl Scene { return v; } } - let v = self.document.header.paper_space_linetype_scaling - || self - .document - .entities() - .any(|e| crate::scene::annotative::is_annotative(&self.document, e)); + let v = self + .document + .entities() + .any(|e| crate::scene::annotative::is_annotative(&self.document, e)); self.annotation_affects_wires .set(Some((self.geometry_epoch, v))); v @@ -3326,7 +3413,7 @@ impl Scene { anno_scale_override: Option, frozen_layers: Option<&HashSet>, ) -> Option>> { - let perf = std::env::var_os("OCS_PERF").is_some(); + let perf = crate::perf::enabled(); let t_patch = iced::time::Instant::now(); // The entry must exist, be stale, and be uniquely held so we can move // its wires out rather than deep-clone them. @@ -3339,7 +3426,7 @@ impl Scene { let strong = Arc::strong_count(&entry.wires); if strong != 1 { if perf { - eprintln!("[perf] resident-shared strong={strong}"); + crate::perf_record!("[perf] resident-shared strong={strong}"); } return None; } @@ -3564,7 +3651,7 @@ impl Scene { }, ); if perf { - eprintln!( + crate::perf_record!( "[perf] resident-patch {:>7.1}ms wires={} changes={}", t_patch.elapsed().as_secs_f64() * 1000.0, arc.len(), @@ -3582,9 +3669,9 @@ impl Scene { fn paper_sheet_wires_arc(&self) -> Arc> { { let cache = self.paper_sheet_cache.borrow(); - if let Some((epoch, gen, ref arc)) = *cache { - if epoch == self.geometry_epoch { - self.last_model_wire_gen.set(gen); + if let Some((epoch, gen, arc)) = cache.get(&self.current_layout) { + if *epoch == self.geometry_epoch { + self.last_model_wire_gen.set(*gen); return Arc::clone(arc); } } @@ -3606,7 +3693,12 @@ impl Scene { let arc = Arc::new(wires); let gen = WIRE_CONTENT_GEN.fetch_add(1, Ordering::Relaxed); self.last_model_wire_gen.set(gen); - *self.paper_sheet_cache.borrow_mut() = Some((self.geometry_epoch, gen, Arc::clone(&arc))); + let mut cache = self.paper_sheet_cache.borrow_mut(); + cache.retain(|_, (epoch, _, _)| *epoch == self.geometry_epoch); + cache.insert( + self.current_layout.clone(), + (self.geometry_epoch, gen, Arc::clone(&arc)), + ); arc } @@ -3673,7 +3765,7 @@ impl Scene { } } } - let perf = std::env::var_os("OCS_PERF").is_some(); + let perf = crate::perf::enabled(); let t_depth = iced::time::Instant::now(); // Replay Add/Remove into the retained block order. This avoids rescanning @@ -3754,7 +3846,7 @@ impl Scene { cache.depths = Arc::clone(&arc); *self.draw_depth_cache.borrow_mut() = Some(cache); if perf { - eprintln!( + crate::perf_record!( "[perf] draw-depth-patch {:>7.1}ms entries={} changes={}", t_depth.elapsed().as_secs_f64() * 1000.0, arc.len(), @@ -3837,7 +3929,7 @@ impl Scene { owners, }); if perf { - eprintln!( + crate::perf_record!( "[perf] draw-depth {:>7.1}ms entries={}", t_depth.elapsed().as_secs_f64() * 1000.0, arc.len(), @@ -3854,15 +3946,16 @@ impl Scene { // drawings. Key on a signature of `selected` instead, so hover (which // never changes `selected`) keeps the cache warm. let sel_sig = self.selected_set_sig(); + let space = self.current_layout.clone(); { let reuse = { let cache = self.hatch_cache.borrow(); - match *cache { + match cache.get(&space) { // Selection tint is baked in, so the selected set must also // match; category = a changed handle that is a hatch/solid fill. - Some((cached_epoch, cached_sel, ref arc)) - if cached_sel == sel_sig - && self.category_cache_valid(cached_epoch, |h| { + Some((cached_epoch, cached_sel, arc)) + if *cached_sel == sel_sig + && self.category_cache_valid(*cached_epoch, |h| { self.hatches.contains_key(&h) }) => { @@ -3872,14 +3965,17 @@ impl Scene { } }; if let Some(arc) = reuse { - if let Some((ref mut e, _, _)) = *self.hatch_cache.borrow_mut() { + if let Some((e, _, _)) = self.hatch_cache.borrow_mut().get_mut(&space) { *e = self.geometry_epoch; } return arc; } } let arc = Arc::new(self.synced_hatch_models(None)); - *self.hatch_cache.borrow_mut() = Some((self.geometry_epoch, sel_sig, Arc::clone(&arc))); + self.hatch_cache.borrow_mut().insert( + space, + (self.geometry_epoch, sel_sig, Arc::clone(&arc)), + ); arc } @@ -3895,14 +3991,15 @@ impl Scene { } pub(super) fn wipeout_models_arc(&self) -> Arc> { + let space = self.current_layout.clone(); { let reuse = { let cache = self.wipeout_cache.borrow(); - match *cache { - Some((cached_epoch, ref arc)) + match cache.get(&space) { + Some((cached_epoch, arc)) // wipeout_models scans the whole document for Wipeout // entities; relevance = the changed handle is a Wipeout. - if self.category_cache_valid(cached_epoch, |h| { + if self.category_cache_valid(*cached_epoch, |h| { matches!( self.document.get_entity(h), Some(EntityType::Wipeout(_)) @@ -3915,14 +4012,16 @@ impl Scene { } }; if let Some(arc) = reuse { - if let Some((ref mut e, _)) = *self.wipeout_cache.borrow_mut() { + if let Some((e, _)) = self.wipeout_cache.borrow_mut().get_mut(&space) { *e = self.geometry_epoch; } return arc; } } let arc = Arc::new(self.wipeout_models(None)); - *self.wipeout_cache.borrow_mut() = Some((self.geometry_epoch, Arc::clone(&arc))); + self.wipeout_cache + .borrow_mut() + .insert(space, (self.geometry_epoch, Arc::clone(&arc))); arc } @@ -4006,16 +4105,17 @@ impl Scene { } pub(super) fn meshes_arc(&self) -> Arc> { + let space = self.current_layout.clone(); { let reuse = { let cache = self.mesh_cache.borrow(); - match *cache { + match cache.get(&space) { // Top-level solids seed self.meshes; the instanced_block part // is driven by INSERTs, so an INSERT edit (e.g. a move) must // also invalidate. Block-definition edits route through // bump_geometry (a full delta) and invalidate regardless. - Some((cached_epoch, ref arc)) - if self.category_cache_valid(cached_epoch, |h| { + Some((cached_epoch, arc)) + if self.category_cache_valid(*cached_epoch, |h| { self.meshes.contains_key(&h) || matches!( self.document.get_entity(h), @@ -4029,14 +4129,16 @@ impl Scene { } }; if let Some(arc) = reuse { - if let Some((ref mut e, _)) = *self.mesh_cache.borrow_mut() { + if let Some((e, _)) = self.mesh_cache.borrow_mut().get_mut(&space) { *e = self.geometry_epoch; } return arc; } } let arc = Arc::new(self.mesh_models(None)); - *self.mesh_cache.borrow_mut() = Some((self.geometry_epoch, Arc::clone(&arc))); + self.mesh_cache + .borrow_mut() + .insert(space, (self.geometry_epoch, Arc::clone(&arc))); arc } @@ -4248,8 +4350,9 @@ impl Scene { return self.hatch_models_arc(); } let sig = Self::frozen_layers_sig(frozen); + let key = (self.current_layout.clone(), sig); let sel = self.selected_set_sig(); - if let Some((e, s, arc)) = self.frozen_hatch_cache.borrow().get(&sig) { + if let Some((e, s, arc)) = self.frozen_hatch_cache.borrow().get(&key) { if *e == self.geometry_epoch && *s == sel { return Arc::clone(arc); } @@ -4257,7 +4360,7 @@ impl Scene { let arc = Arc::new(self.synced_hatch_models(Some(frozen))); self.frozen_hatch_cache .borrow_mut() - .insert(sig, (self.geometry_epoch, sel, Arc::clone(&arc))); + .insert(key, (self.geometry_epoch, sel, Arc::clone(&arc))); arc } @@ -4270,7 +4373,8 @@ impl Scene { return self.wipeout_models_arc(); } let sig = Self::frozen_layers_sig(frozen); - if let Some((e, arc)) = self.frozen_wipeout_cache.borrow().get(&sig) { + let key = (self.current_layout.clone(), sig); + if let Some((e, arc)) = self.frozen_wipeout_cache.borrow().get(&key) { if *e == self.geometry_epoch { return Arc::clone(arc); } @@ -4278,7 +4382,7 @@ impl Scene { let arc = Arc::new(self.wipeout_models(Some(frozen))); self.frozen_wipeout_cache .borrow_mut() - .insert(sig, (self.geometry_epoch, Arc::clone(&arc))); + .insert(key, (self.geometry_epoch, Arc::clone(&arc))); arc } @@ -4306,7 +4410,8 @@ impl Scene { return self.meshes_arc(); } let sig = Self::frozen_layers_sig(frozen); - if let Some((e, arc)) = self.frozen_mesh_cache.borrow().get(&sig) { + let key = (self.current_layout.clone(), sig); + if let Some((e, arc)) = self.frozen_mesh_cache.borrow().get(&key) { if *e == self.geometry_epoch { return Arc::clone(arc); } @@ -4314,7 +4419,7 @@ impl Scene { let arc = Arc::new(self.mesh_models(Some(frozen))); self.frozen_mesh_cache .borrow_mut() - .insert(sig, (self.geometry_epoch, Arc::clone(&arc))); + .insert(key, (self.geometry_epoch, Arc::clone(&arc))); arc } @@ -4777,12 +4882,13 @@ impl Scene { // interaction index survives pan/zoom and reaches every entity. return self.entity_wires_arc(); } - let layout_block = self.current_layout_block_handle(); match self.active_viewport { None => self.paper_sheet_wires_arc(), - Some(vp_handle) => { - Arc::new(self.viewport_content_wires(layout_block, Some(vp_handle), None)) - } + // MSPACE editing already uses the viewport's model camera, so its + // interaction source must stay in model coordinates too. The old + // projected-Paper copy rebuilt a large Vec/Arc and interaction + // index on entry even though the resident model set already exists. + Some(vp_handle) => self.model_wires_for_viewport_arc(vp_handle, 0.0), } } @@ -5052,7 +5158,7 @@ impl Scene { allow_pending_empty: bool, ) -> crate::scene::pick::interaction_index::InteractionCandidates { if let Some((base_epoch, base, changes)) = self.interaction_overlay_base() { - let perf = std::env::var_os("OCS_PERF").is_some(); + let perf = crate::perf::enabled(); let t0 = iced::time::Instant::now(); let keys = base.query_wire_keys_xy(aabb); let key_ms = t0.elapsed().as_secs_f64() * 1000.0; @@ -5076,7 +5182,7 @@ impl Scene { let remap_ms = t_remap.elapsed().as_secs_f64() * 1000.0; let total_ms = t0.elapsed().as_secs_f64() * 1000.0; if perf && total_ms >= 50.0 { - eprintln!( + crate::perf_record!( "[perf] interaction-overlay {:>7.1}ms keys={} wires={} query={:.1} changed={:.1} gather={:.1} remap={:.1}", total_ms, keys.len(), @@ -5111,7 +5217,7 @@ impl Scene { allow_pending_empty: bool, ) -> crate::scene::pick::interaction_index::InteractionCandidates { if let Some((base_epoch, base, changes)) = self.interaction_overlay_base() { - let perf = std::env::var_os("OCS_PERF").is_some(); + let perf = crate::perf::enabled(); let t0 = iced::time::Instant::now(); let keys = base.query_wire_keys_screen(screen_rect, view_rot, eye, bounds); let key_ms = t0.elapsed().as_secs_f64() * 1000.0; @@ -5146,7 +5252,7 @@ impl Scene { let remap_ms = t_remap.elapsed().as_secs_f64() * 1000.0; let total_ms = t0.elapsed().as_secs_f64() * 1000.0; if perf && total_ms >= 50.0 { - eprintln!( + crate::perf_record!( "[perf] interaction-overlay {:>7.1}ms keys={} wires={} query={:.1} changed={:.1} gather={:.1} remap={:.1}", total_ms, keys.len(), diff --git a/src/scene/mspace.rs b/src/scene/mspace.rs index f5688408c..aa4bd5b6c 100644 --- a/src/scene/mspace.rs +++ b/src/scene/mspace.rs @@ -434,14 +434,14 @@ impl Scene { py: f32, canvas: (f32, f32), ) -> Option { - let layout_block = self.current_layout_block_handle(); - self.document - .entities() - .filter_map(|e| { - let EntityType::Viewport(vp) = e else { + let (_, _, handles) = self.paper_viewport_handles(); + handles + .iter() + .filter_map(|handle| { + let Some(EntityType::Viewport(vp)) = self.document.get_entity(*handle) else { return None; }; - if !self.is_content_viewport_in_layout(vp, layout_block) || !vp.status.is_on { + if !vp.status.is_on { return None; } let rect = self.viewport_screen_rect(vp.common.handle, canvas)?; @@ -465,14 +465,12 @@ impl Scene { /// Return the handle of the first active user viewport in the current layout, /// or `None` if there are none. Used by the MS command. pub fn first_user_viewport(&self) -> Option { - let layout_block = self.current_layout_block_handle(); - self.document.entities().find_map(|e| { - let EntityType::Viewport(vp) = e else { + let (_, _, handles) = self.paper_viewport_handles(); + handles.iter().find_map(|handle| { + let Some(EntityType::Viewport(vp)) = self.document.get_entity(*handle) else { return None; }; - if self.is_content_viewport_in_layout(vp, layout_block) - && vp.status.is_on - { + if vp.status.is_on { Some(vp.common.handle) } else { None diff --git a/src/scene/paper.rs b/src/scene/paper.rs index db7097537..8e9cf83c5 100644 --- a/src/scene/paper.rs +++ b/src/scene/paper.rs @@ -2,6 +2,71 @@ use super::*; impl Scene { + pub(super) fn paper_viewport_handles( + &self, + ) -> (Handle, Handle, Arc>) { + { + let cache = self.paper_viewport_cache.borrow(); + if let Some(cache) = cache.get(&self.current_layout) { + if cache.epoch == self.geometry_epoch && cache.layout == self.current_layout { + return ( + cache.layout_block, + cache.sheet, + Arc::clone(&cache.content), + ); + } + } + } + + let layout_block = self.current_layout_block_handle(); + let sheet = self.current_layout_sheet_viewport_handle(); + let is_content = |handle: Handle| { + let Some(EntityType::Viewport(vp)) = self.document.get_entity(handle) else { + return false; + }; + vp.common.owner_handle == layout_block + && if sheet.is_valid() { + handle != sheet + } else { + Self::is_content_viewport(vp) + } + }; + let content = if let Some(block) = self + .document + .block_records + .iter() + .find(|block| block.handle == layout_block) + .filter(|block| !block.entity_handles.is_empty()) + { + block + .entity_handles + .iter() + .copied() + .filter(|handle| is_content(*handle)) + .collect() + } else { + self.document + .entities() + .filter_map(|entity| { + let handle = entity.common().handle; + is_content(handle).then_some(handle) + }) + .collect() + }; + let content = Arc::new(content); + self.paper_viewport_cache.borrow_mut().insert( + self.current_layout.clone(), + PaperViewportCache { + epoch: self.geometry_epoch, + layout: self.current_layout.clone(), + layout_block, + sheet, + content: Arc::clone(&content), + }, + ); + (layout_block, sheet, content) + } + pub fn grid_views(&self, vw: f32, vh: f32) -> Vec<(iced::Rectangle, Camera, Handle)> { self.active_viewports(vw, vh, acadrust::entities::ViewportRenderMode::Wireframe2D) .into_iter() @@ -58,7 +123,7 @@ impl Scene { }) .collect(); } - let layout_block = self.current_layout_block_handle(); + let (_, sheet_handle, content_handles) = self.paper_viewport_handles(); let mut out: Vec = Vec::new(); // The full-canvas sheet viewport renders the paper-space entities // themselves — the layout's own view, drawn first so the floating @@ -72,7 +137,7 @@ impl Scene { sheet_cam.projection = view::camera::Projection::Orthographic; let sheet_grid_on = match self .document - .get_entity(self.current_layout_sheet_viewport_handle()) + .get_entity(sheet_handle) { Some(EntityType::Viewport(vp)) => vp.status.grid_on, _ => false, @@ -92,13 +157,11 @@ impl Scene { grid_on: sheet_grid_on, paper_sheet: true, }); - for e in self.document.entities() { - let EntityType::Viewport(vp) = e else { + for &handle in content_handles.iter() { + let Some(EntityType::Viewport(vp)) = self.document.get_entity(handle) else { continue; }; - if !self.is_content_viewport_in_layout(vp, layout_block) - || !vp.status.is_on - { + if !vp.status.is_on { continue; } let h = vp.common.handle; @@ -122,6 +185,54 @@ impl Scene { out } + pub(super) fn paper_sheet_render_models( + &self, + ) -> ( + Arc>, + Arc>, + Arc>, + ) { + let selected = self.selected_set_sig(); + { + let cache = self.paper_sheet_render_cache.borrow(); + if let Some(cache) = cache.get(&self.current_layout) { + if cache.epoch == self.geometry_epoch + && cache.layout == self.current_layout + && cache.selected == selected + && cache.paper_bg == self.paper_bg_color + { + return ( + Arc::clone(&cache.hatches), + Arc::clone(&cache.wipeouts), + Arc::clone(&cache.images), + ); + } + } + } + + let mut hatches = Vec::new(); + if let Some(sheet) = self.paper_sheet_fill() { + hatches.push(sheet); + } + hatches.extend(self.paper_canvas_hatches().iter().cloned()); + let hatches = Arc::new(hatches); + let wipeouts = self.paper_canvas_wipeouts(); + let images = self.paper_sheet_images(); + self.paper_sheet_render_cache.borrow_mut().insert( + self.current_layout.clone(), + PaperSheetRenderCache { + epoch: self.geometry_epoch, + layout: self.current_layout.clone(), + selected, + paper_bg: self.paper_bg_color, + hatches: Arc::clone(&hatches), + wipeouts: Arc::clone(&wipeouts), + images: Arc::clone(&images), + }, + ); + (hatches, wipeouts, images) + } + /// Convert a paper-space Viewport entity's position/size into a pixel /// `Rectangle` relative to the top-left of the canvas. /// @@ -434,41 +545,31 @@ impl Scene { ) -> Arc> { use rustc_hash::FxHashSet as HSet; - // Only content-REAL per-viewport parameters remain: the viewport's own - // frozen-layer set and its annotation/PSLTSCALE scale. Frustum cull and - // zoom LOD are gone — every space (Model tiles, BEDIT, the sheet, and - // content viewports) shares the same un-culled, LOD-free resident - // infrastructure, so viewports with default parameters all reuse ONE - // static-hold entry (and its stable content id) instead of re-baking - // per viewport per zoom step. - let (frozen, vp_anno_scale) = match self.document.get_entity(vp_handle) { + // The viewport's frozen-layer set is the only resident-geometry input. + // Its live zoom is camera magnification, not CANNOSCALE: tying + // annotation geometry to view_height rebuilt the entire model on every + // wheel tick whenever the drawing contained one annotative object. + // Explicit annotation-scale changes still rebuild through + // `self.annotation_scale`; PSLTSCALE is a viewport GPU uniform. + let frozen = match self.document.get_entity(vp_handle) { Some(EntityType::Viewport(vp)) => { let f: HSet = vp.frozen_layers.iter().cloned().collect(); - let vp_scale = - vp_effective_scale(vp.custom_scale, vp.view_height, vp.height); - let anno = if vp_scale > 1e-9 { - (1.0 / vp_scale) as f32 - } else { - 1.0_f32 - }; - (f, anno) + f } - _ => (HSet::default(), 1.0_f32), + _ => HSet::default(), }; self.resident_wires_for( self.model_space_block_handle(), - Some(vp_anno_scale), + Some(self.annotation_scale), Some(&frozen), ) } /// Resident model wires for a paper content viewport. Just the unified /// static-hold (`resident_wires_for`) keyed on the viewport's frozen set + - /// annotation scale — no per-viewport height/view cache anymore: the set is - /// camera-independent (no cull, no LOD), so paper zoom and MSPACE frustum - /// changes reuse it as-is, and viewports with default parameters all share - /// one entry (and one stable content id). + /// explicit CANNOSCALE — no per-viewport height/view cache: the set is + /// camera-independent, so paper zoom and MSPACE zoom reuse it as-is. pub(crate) fn model_wires_for_viewport_arc( &self, vp_handle: Handle, diff --git a/src/scene/pick/interaction_index.rs b/src/scene/pick/interaction_index.rs index 70c027cbe..6edb0e007 100644 --- a/src/scene/pick/interaction_index.rs +++ b/src/scene/pick/interaction_index.rs @@ -748,7 +748,7 @@ impl InteractionIndex { pub fn build(wires: &[WireModel]) -> Self { #[cfg(not(target_arch = "wasm32"))] - let perf = std::env::var_os("OCS_PERF").is_some(); + let perf = crate::perf::enabled(); #[cfg(not(target_arch = "wasm32"))] let build_started = std::time::Instant::now(); let wire_handles: Vec> = wires @@ -930,7 +930,7 @@ impl InteractionIndex { ); #[cfg(not(target_arch = "wasm32"))] if perf { - eprintln!( + crate::perf_record!( "[perf] interaction-index-detail total={:.1}ms handles={:.1} collect={:.1} flatten={:.1} spatial={:.1}", build_started.elapsed().as_secs_f64() * 1000.0, handles_elapsed.as_secs_f64() * 1000.0, diff --git a/src/scene/pipeline/uniforms.rs b/src/scene/pipeline/uniforms.rs index 72517fa58..e9197ac07 100644 --- a/src/scene/pipeline/uniforms.rs +++ b/src/scene/pipeline/uniforms.rs @@ -21,9 +21,12 @@ pub struct Uniforms { /// 0.0 = force opaque). Read by the wire shader so the toggle does not /// require a retessellate. pub transparency_enable: f32, - /// Pads the struct to 112 B (next multiple of 16) so wgpu's uniform - /// alignment rules are satisfied. - pub _pad: [f32; 2], + /// Per-viewport PSLTSCALE factor. Kept in the shared frame uniform so + /// zooming inside MSPACE changes one scalar instead of re-tessellating and + /// re-uploading every dashed wire. + pub linetype_scale: f32, + /// Pads the struct to the uniform alignment required by wgpu. + pub _pad: f32, // ── Relative-to-eye (double-single) additions ─────────────────────────── // Appended at the end so existing field offsets are unchanged; shaders that @@ -54,7 +57,8 @@ impl Uniforms { lwdisplay_enable: if lwdisplay_enable { 1.0 } else { 0.0 }, flat_shade: 0.0, transparency_enable: 1.0, - _pad: [0.0; 2], + linetype_scale: 1.0, + _pad: 0.0, view_rot: camera.view_proj_rte(bounds), eye_high, _pad_eh: 0.0, diff --git a/src/scene/pipeline/wire_arena.rs b/src/scene/pipeline/wire_arena.rs index 63dd7fa9f..e3ab69123 100644 --- a/src/scene/pipeline/wire_arena.rs +++ b/src/scene/pipeline/wire_arena.rs @@ -309,7 +309,7 @@ impl WireArena { mesh_edge: bool, ) -> Option { let ranges = handle_ranges(wires)?; - let perf = std::env::var_os("OCS_PERF").is_some(); + let perf = crate::perf::enabled(); let total_started = std::time::Instant::now(); // Reject an oversized batch before parallel emission allocates hundreds @@ -447,7 +447,7 @@ impl WireArena { 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!( + crate::perf_record!( "[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, @@ -740,13 +740,13 @@ impl WireArena { .collect(); } - if std::env::var_os("OCS_PERF").is_some() { + if crate::perf::enabled() { let submitted: u64 = ranges .iter() .map(|(start, end)| (end - start) as u64) .sum(); if submitted < self.inst_tail as u64 { - eprintln!( + crate::perf_record!( "[perf] wire-cull submitted={} resident={} ranges={}", submitted, self.inst_tail, diff --git a/src/scene/project.rs b/src/scene/project.rs index d2787554b..948c0e30e 100644 --- a/src/scene/project.rs +++ b/src/scene/project.rs @@ -12,18 +12,15 @@ impl Scene { ) -> Vec { use acadrust::entities::Viewport; - let viewports: Vec<&Viewport> = self - .document - .entities() - .filter_map(|e| { - if let EntityType::Viewport(vp) = e { - Some(vp) - } else { - None - } + let (_, _, viewport_handles) = self.paper_viewport_handles(); + let viewports: Vec<&Viewport> = viewport_handles + .iter() + .filter_map(|handle| match self.document.get_entity(*handle) { + Some(EntityType::Viewport(vp)) => Some(vp), + _ => None, }) .filter(|vp| { - self.is_content_viewport_in_layout(vp, paper_block) + vp.common.owner_handle == paper_block && vp.status.is_on && only_vp.map_or(true, |h| vp.common.handle == h) && exclude_vp.map_or(true, |h| vp.common.handle != h) @@ -328,12 +325,17 @@ impl Scene { }; out.color = [r * 0.80, g * 0.80, b * 0.80, a * 0.85]; out.line_weight_px = wire.line_weight_px; - // Wire's pattern was sized for model-space coords during - // tessellation; we just projected points into paper coords - // (× scale), so rescale the dash pattern by the same factor - // to keep dimensional consistency in the GPU shader. - out.pattern_length = wire.pattern_length * scale; - out.pattern = wire.pattern.map(|v| v * scale); + // Wire patterns stay in their base model units. Projection + // normally scales them with the geometry; PSLTSCALE instead + // keeps dash sizes constant in paper units (the on-screen path + // applies the same factor through its viewport uniform). + let dash_scale = if self.document.header.paper_space_linetype_scaling { + 1.0 + } else { + scale + }; + out.pattern_length = wire.pattern_length * dash_scale; + out.pattern = wire.pattern.map(|v| v * dash_scale); projected.push(out); } diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index f3961ebd2..60524d43a 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -14,7 +14,10 @@ use std::sync::Arc; use crate::scene::pipeline::viewcube::{hover_id, VIEWCUBE_PX}; use crate::scene::pipeline::MultiPipeline; use crate::scene::convert::tess_util; -use crate::scene::{HatchModel, ImageModel, MeshLodSet, Scene, Uniforms, ViewportInstance, WireModel}; +use crate::scene::{ + vp_effective_scale, HatchModel, ImageModel, MeshLodSet, NavPerfSample, Scene, Uniforms, + ViewportInstance, WireModel, +}; // ── Camera hover state (shader::Program::State) ─────────────────────────── @@ -156,6 +159,8 @@ pub struct Primitive { /// `prepare` calls run before all `render` calls, so disjoint slots are /// safe. pub(in crate::scene) base_slot: usize, + /// One input-to-render sample, carried only when PERF tracing is enabled. + pub(in crate::scene) nav_perf: Option, } /// Flags the render pipeline consumes, derived from @@ -241,6 +246,7 @@ impl shader::Primitive for Primitive { bounds: &Rectangle, viewport: &Viewport, ) { + let nav_prepare_started = std::time::Instant::now(); let phys = viewport.physical_size(); let full_size = Size::new(phys.width, phys.height); let scale = viewport.scale_factor() as f32; @@ -438,7 +444,7 @@ impl shader::Primitive for Primitive { // 2D/3D sets fall through to the shared batched path below. let mut arena_served = false; #[cfg(not(target_arch = "wasm32"))] - let _perf = std::env::var_os("OCS_PERF").is_some(); + let _perf = crate::perf::enabled(); #[cfg(not(target_arch = "wasm32"))] let _t0 = std::time::Instant::now(); #[cfg(not(target_arch = "wasm32"))] @@ -455,7 +461,7 @@ impl shader::Primitive for Primitive { }); let patch = vp.wire_patch.as_ref().map(|(_, patch)| patch); if _perf { - eprintln!( + crate::perf_record!( "[perf] arena-base ok={} held={} patch={:?} changes={}", base_ok, inner.wire_arena_id, @@ -725,7 +731,7 @@ impl shader::Primitive for Primitive { } else { "arena-build" }; - eprintln!( + crate::perf_record!( "[perf] wire {:>7.1}ms {:<18} wires={} gpu_instances={}", _t0.elapsed().as_secs_f64() * 1000.0, outcome, @@ -862,6 +868,19 @@ impl shader::Primitive for Primitive { ); } } + if let Some(sample) = self.nav_perf { + crate::perf::record(format_args!( + "[perf] nav-prepare op={} space={} mode={} input={:.2}ms build={:.2}ms prepare={:.2}ms elapsed={:.2}ms viewports={}", + sample.op.label(), + sample.space, + sample.mode, + sample.input_ms, + sample.build_ms, + nav_prepare_started.elapsed().as_secs_f64() * 1000.0, + sample.started.elapsed().as_secs_f64() * 1000.0, + self.viewports.len(), + )); + } } fn render( @@ -871,6 +890,7 @@ impl shader::Primitive for Primitive { target: &iced::wgpu::TextureView, clip: &Rectangle, ) { + let nav_render_started = std::time::Instant::now(); let cw = clip.width as f32; let ch = clip.height as f32; let clip_right = clip.x + clip.width; @@ -934,6 +954,17 @@ impl shader::Primitive for Primitive { inner.viewcube.render(encoder, target, vp_clip); } } + if let Some(sample) = self.nav_perf { + crate::perf::record(format_args!( + "[perf] nav-render op={} space={} mode={} encode={:.2}ms elapsed={:.2}ms viewports={}", + sample.op.label(), + sample.space, + sample.mode, + nav_render_started.elapsed().as_secs_f64() * 1000.0, + sample.started.elapsed().as_secs_f64() * 1000.0, + self.viewports.len(), + )); + } } } @@ -1375,6 +1406,8 @@ impl Scene { _hover_region: Option, show_viewcube: bool, ) -> Primitive { + let nav_build_started = std::time::Instant::now(); + let perf_nav = self.take_nav_perf(); // Hover comes from the scene cell driven by the app-level // `CursorMoved` handler — the cube overlay sits above the shader // and would otherwise mask the move event from `Program::update`. @@ -1396,10 +1429,19 @@ impl Scene { .collect(); // Empty viewports → blit nothing; the container background (model bg // or the paper desk colour) stays visible. + let perf_nav = perf_nav.map(|mut sample| { + sample.build_ms = nav_build_started.elapsed().as_secs_f64() * 1000.0; + sample + }); + // Model panes permanently own slots 0..N. Paper starts after them so + // its sheet/content viewports never evict the Model slot's wire arena, + // textures, mesh batches and render cache during a layout-tab switch. + let base_slot = self.model_tiles.borrow().len(); Primitive { viewports, bg_color, - base_slot: 0, + base_slot, + nav_perf: perf_nav, } } @@ -1425,10 +1467,17 @@ impl Scene { viewports: vec![], bg_color, base_slot: tile_idx, + nav_perf: None, }; }; let active = self.active_model_tile.get(); let is_active = tile_idx == active; + let nav_build_started = std::time::Instant::now(); + let perf_nav = if is_active { + self.take_nav_perf() + } else { + None + }; let camera = if is_active { self.camera.borrow().clone() } else { @@ -1458,10 +1507,15 @@ impl Scene { .viewport_data_for(&inst, canvas, hover_region, show_viewcube) .into_iter() .collect(); + let perf_nav = perf_nav.map(|mut sample| { + sample.build_ms = nav_build_started.elapsed().as_secs_f64() * 1000.0; + sample + }); Primitive { viewports, bg_color, base_slot: tile_idx, + nav_perf: perf_nav, } } @@ -1612,6 +1666,19 @@ impl Scene { }; let mut uniforms = Uniforms::new(&inst.camera, full_bounds, self.document.header.lineweight_display); + if self.document.header.paper_space_linetype_scaling + && !inst.paper_sheet + && inst.tile_idx.is_none() + && inst.handle != Handle::NULL + { + if let Some(EntityType::Viewport(vp)) = self.document.get_entity(inst.handle) { + let viewport_scale = + vp_effective_scale(vp.custom_scale, vp.view_height, vp.height); + if viewport_scale.is_finite() && viewport_scale > 1e-9 { + uniforms.linetype_scale = (1.0 / viewport_scale) as f32; + } + } + } // Crop the rotation-only RTE view-projection to the visible sub-rect. uniforms.view_rot = crop_view_proj(uniforms.view_rot, uo, vo, us, vs); uniforms.viewport_size = [visible_w, visible_h]; @@ -1655,21 +1722,18 @@ impl Scene { rustc_hash::FxHashSet::default() }; - let (hatches, wipeout_hatches) = if inst.paper_sheet { - let mut v: Vec = Vec::new(); - if let Some(sheet) = self.paper_sheet_fill() { - v.push(sheet); - } - v.extend(self.paper_canvas_hatches().iter().cloned()); - (Arc::new(v), self.paper_canvas_wipeouts()) + let (hatches, wipeout_hatches, paper_images) = if inst.paper_sheet { + let (hatches, wipeouts, images) = self.paper_sheet_render_models(); + (hatches, wipeouts, Some(images)) } else { ( self.hatch_models_for_viewport(&vp_frozen), self.wipeout_models_for_viewport(&vp_frozen), + None, ) }; - let images = if inst.paper_sheet { - self.paper_sheet_images() + let images = if let Some(images) = paper_images { + images } else { self.images_for_viewport(&vp_frozen) }; diff --git a/src/shaders/wire.wgsl b/src/shaders/wire.wgsl index ee51c2ceb..f6a524655 100644 --- a/src/shaders/wire.wgsl +++ b/src/shaders/wire.wgsl @@ -28,7 +28,8 @@ struct Uniforms { // Transparency-display toggle: 1.0 = honour baked alpha, 0.0 = force // every line opaque. transparency_enable: f32, - _pad: vec2, + linetype_scale: f32, + _pad: f32, // ── Relative-to-eye (double-single) ────────────────────────────────── // view_rot is the rotation-only view-projection; vertices subtract the eye // (eye_high + eye_low, two f32 emulating f64) before transforming, so the @@ -166,10 +167,13 @@ fn resolve_hw(taper: f32, world_hw: f32, px_hw: f32) -> f32 { // Smallest non-zero dash / gap element, in world units. Used by // the fragment stage to decide when the pattern's finest feature // would render below one pixel and should collapse to a solid line. - var min_elem: f32 = in.dists.w; + let lt_scale = u.linetype_scale; + var min_elem: f32 = in.dists.w * lt_scale; let elems = array( - in.pat0.x, in.pat0.y, in.pat0.z, in.pat0.w, - in.pat1.x, in.pat1.y, in.pat1.z, in.pat1.w, + in.pat0.x * lt_scale, in.pat0.y * lt_scale, + in.pat0.z * lt_scale, in.pat0.w * lt_scale, + in.pat1.x * lt_scale, in.pat1.y * lt_scale, + in.pat1.z * lt_scale, in.pat1.w * lt_scale, ); for (var i = 0u; i < 8u; i++) { let e = abs(elems[i]); @@ -186,11 +190,11 @@ fn resolve_hw(taper: f32, world_hw: f32, px_hw: f32) -> f32 { + ext * hw * u.world_per_pixel; out.cap = vec2(which_end * seg_len + ext * hw, hw * side); out.cap_ends = vec3(seg_len, hw_a, hw_b); - out.pattern_length = in.dists.w; - out.pat0 = in.pat0; - out.pat1 = in.pat1; + out.pattern_length = in.dists.w * lt_scale; + out.pat0 = in.pat0 * lt_scale; + out.pat1 = in.pat1 * lt_scale; out.min_elem = min_elem; - out.align_end = in.misc.y; + out.align_end = in.misc.y * lt_scale; out.align_total = in.misc.z; return out; } diff --git a/src/shaders/wire_indexed.wgsl b/src/shaders/wire_indexed.wgsl index 0ce06d60d..7693c950c 100644 --- a/src/shaders/wire_indexed.wgsl +++ b/src/shaders/wire_indexed.wgsl @@ -11,7 +11,8 @@ struct Uniforms { lwdisplay_enable: f32, flat_shade: f32, transparency_enable: f32, - _pad: vec2, + linetype_scale: f32, + _pad: f32, view_rot: mat4x4, eye_high: vec3, _pad_eh: f32, @@ -132,10 +133,13 @@ fn resolve_hw(taper_ratio: f32, world_hw: f32, px_hw: f32) -> f32 { let ndc_offset = offset_px / (u.viewport_size * 0.5); let final_clip = clip_pos + vec4(ndc_offset * clip_pos.w, 0.0, 0.0); - var min_elem: f32 = c.pattern_length; + let lt_scale = u.linetype_scale; + var min_elem: f32 = c.pattern_length * lt_scale; let elems = array( - c.pat0.x, c.pat0.y, c.pat0.z, c.pat0.w, - c.pat1.x, c.pat1.y, c.pat1.z, c.pat1.w, + c.pat0.x * lt_scale, c.pat0.y * lt_scale, + c.pat0.z * lt_scale, c.pat0.w * lt_scale, + c.pat1.x * lt_scale, c.pat1.y * lt_scale, + c.pat1.z * lt_scale, c.pat1.w * lt_scale, ); for (var i = 0u; i < 8u; i++) { let e = abs(elems[i]); @@ -152,11 +156,11 @@ fn resolve_hw(taper_ratio: f32, world_hw: f32, px_hw: f32) -> f32 { + ext * hw * u.world_per_pixel; out.cap = vec2(which_end * seg_len + ext * hw, hw * side); out.cap_ends = vec3(seg_len, hw_a, hw_b); - out.pattern_length = c.pattern_length; - out.pat0 = c.pat0; - out.pat1 = c.pat1; + out.pattern_length = c.pattern_length * lt_scale; + out.pat0 = c.pat0 * lt_scale; + out.pat1 = c.pat1 * lt_scale; out.min_elem = min_elem; - out.align_end = c.align_end; + out.align_end = c.align_end * lt_scale; out.align_total = c.align_total; return out; }