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.
This commit is contained in:
Hakan Seven 2026-07-25 13:34:48 +03:00
commit 12bf126646
26 changed files with 794 additions and 325 deletions

View file

@ -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());
}

View file

@ -234,6 +234,7 @@ pub fn start_allowed(cmd: &str) -> bool {
| "DONATE"
| "WEBVERSION"
| "HELP"
| "PERF"
| "CUI"
| "ALIASEDIT"
| "CUILOAD"

View file

@ -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 {

View file

@ -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<acadrust::Handle>)>,
@ -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.

View file

@ -819,8 +819,8 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
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<Message> {
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(),

View file

@ -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()
}

View file

@ -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<Message> {
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()
}

View file

@ -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),