From e21c5f06ac87da091e8bd9fe3b8dee301dbb0c44 Mon Sep 17 00:00:00 2001 From: sthifen Date: Thu, 15 Oct 2026 20:50:41 -0600 Subject: [PATCH] =?UTF-8?q?Viewport=20frame-rate=20readout=20(Preferences?= =?UTF-8?q?=20=E2=96=B8=20Graphics)=20(#1580)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thanks @sthifen. --- assets/locales/de.json | 2 + assets/locales/en.json | 2 + assets/locales/fr.json | 2 + assets/locales/ja.json | 2 + assets/locales/ko.json | 2 + assets/locales/zh.json | 2 + crates/ui-egui/src/frame_rate.rs | 96 ++++++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 4 ++ crates/ui-egui/src/prefs.rs | 11 ++++ crates/ui-egui/src/viewport.rs | 1 + docs/control-protocol.md | 2 +- 11 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/src/frame_rate.rs diff --git a/assets/locales/de.json b/assets/locales/de.json index e55fd08f..5ee1040f 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -502,6 +502,7 @@ "sample.vents.about": "Langlöcher in einem Rechteckmuster: Zeilen, Spalten und Abstand sind Parameter", "sample.vents.name": "Lüftungsplatte", "settings.display_quality_note": "Feinere Stufen zeigen gekrümmte Flächen und Kanten glatter und brauchen länger zum Vernetzen. Nur die Ansicht ändert sich, nie das Design oder Exporte.", + "settings.frame_rate_note": "Zeigt die Bilder pro Sekunde unten rechts in der Ansicht. Solange sie an ist, wird die Ansicht fortlaufend neu gezeichnet.", "settings.measure_axis_distances": "X/Y/Z-Maße anzeigen", "settings.measure_axis_distances_help": "Bei geöffneter Messung die Abstände entlang der Weltachsen zusätzlich zum Gesamtabstand anzeigen.", "settings.msaa_applies_on_restart": "Die Kantenglättung wird beim nächsten Start von SolidCraft übernommen", @@ -826,6 +827,7 @@ "ui.flat": "Flach", "ui.flip": "Umkehren", "ui.format": "Format", + "ui.frame_rate": "Bildrate", "ui.front": "Vorne", "ui.fully_constrained": "Vollständig bestimmt", "ui.general": "Allgemein", diff --git a/assets/locales/en.json b/assets/locales/en.json index 2230785b..768d01a1 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -718,6 +718,7 @@ "sample.vents.about": "Slots in a rectangular pattern: rows, columns and pitch are parameters", "sample.vents.name": "Vent Plate", "settings.display_quality_note": "Finer levels draw curved faces and edges smoother and take longer to mesh. Only the viewport changes, never the design or exports.", + "settings.frame_rate_note": "Shows frames per second in the view's bottom-right corner. While it is on, the view redraws continuously.", "settings.measure_axis_distances": "Show X/Y/Z distances", "settings.measure_axis_distances_help": "Show world-axis component dimensions alongside the total distance while Measure is open.", "settings.msaa_applies_on_restart": "Anti-aliasing applies the next time SolidCraft starts", @@ -1052,6 +1053,7 @@ "ui.flat": "Flat", "ui.flip": "Flip", "ui.format": "Format", + "ui.frame_rate": "Frame rate", "ui.front": "Front", "ui.fully_constrained": "Fully constrained", "ui.general": "General", diff --git a/assets/locales/fr.json b/assets/locales/fr.json index 88763bf3..f4884104 100644 --- a/assets/locales/fr.json +++ b/assets/locales/fr.json @@ -698,6 +698,7 @@ "sample.vents.about": "Rainures en réseau rectangulaire : lignes, colonnes et pas sont des paramètres", "sample.vents.name": "Plaque d'aération", "settings.display_quality_note": "Les niveaux plus fins affichent plus lisses les faces et arêtes courbes, mais leur maillage prend plus de temps. Seule la vue change, jamais la conception ni les exports.", + "settings.frame_rate_note": "Affiche les images par seconde dans le coin inférieur droit de la vue. Tant qu'elle est activée, la vue est redessinée en continu.", "settings.measure_axis_distances": "Afficher les cotes X/Y/Z", "settings.measure_axis_distances_help": "Afficher les cotes suivant les axes du repère global en plus de la distance totale pendant la mesure.", "settings.msaa_applies_on_restart": "L'anticrénelage s'appliquera au prochain démarrage de SolidCraft", @@ -892,6 +893,7 @@ "ui.flat": "Plat", "ui.flip": "Inverser", "ui.format": "Format", + "ui.frame_rate": "Fréquence d'images", "ui.front": "Avant", "ui.fully_constrained": "Entièrement contrainte", "ui.general": "Général", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index 8f51b893..8afc3832 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -718,6 +718,7 @@ "sample.vents.about": "矩形パターンに並んだスロット: 行数、列数、ピッチはパラメータ", "sample.vents.name": "通気プレート", "settings.display_quality_note": "細かいほど曲面とエッジが滑らかに表示されますが、メッシュの生成に時間がかかります。変わるのはビューポートだけで、設計やエクスポートは変わりません。", + "settings.frame_rate_note": "ビューの右下に 1 秒あたりのフレーム数を表示します。オンの間、ビューは常に再描画されます。", "settings.measure_axis_distances": "計測のX・Y・Z距離を表示", "settings.measure_axis_distances_help": "計測ダイアログを開いている間、2点間距離に加えてワールドX・Y・Z軸方向の寸法線と距離を表示します。", "settings.msaa_applies_on_restart": "アンチエイリアスは次回 SolidCraft を起動したときに適用されます", @@ -1052,6 +1053,7 @@ "ui.flat": "展開", "ui.flip": "反転", "ui.format": "形式", + "ui.frame_rate": "フレームレート", "ui.front": "正面", "ui.fully_constrained": "完全拘束", "ui.general": "一般", diff --git a/assets/locales/ko.json b/assets/locales/ko.json index c532303d..dc99a66a 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -502,6 +502,7 @@ "sample.vents.about": "직사각형 패턴의 슬롯: 행, 열, 피치가 매개변수", "sample.vents.name": "통풍 플레이트", "settings.display_quality_note": "세밀할수록 곡면과 모서리가 더 매끄럽게 표시되지만 메시 생성 시간이 늘어납니다. 뷰포트만 바뀌며 설계나 내보내기는 바뀌지 않습니다.", + "settings.frame_rate_note": "뷰의 오른쪽 아래에 초당 프레임 수를 표시합니다. 켜져 있는 동안 뷰를 계속 다시 그립니다.", "settings.measure_axis_distances": "X/Y/Z 거리 표시", "settings.measure_axis_distances_help": "측정 대화상자가 열려 있는 동안 전체 거리와 함께 월드 축 방향 치수와 거리를 표시합니다.", "settings.msaa_applies_on_restart": "안티앨리어싱은 다음에 SolidCraft를 시작할 때 적용됩니다", @@ -826,6 +827,7 @@ "ui.flat": "플랫", "ui.flip": "뒤집기", "ui.format": "형식", + "ui.frame_rate": "프레임 속도", "ui.front": "정면", "ui.fully_constrained": "완전 구속", "ui.general": "일반", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index 7b756533..e32c8632 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -718,6 +718,7 @@ "sample.vents.about": "按矩形阵列排列的槽:行数、列数和间距均为参数", "sample.vents.name": "通风板", "settings.display_quality_note": "级别越精细,曲面和边显示越平滑,但网格生成耗时更长。只影响视口,不影响设计或导出。", + "settings.frame_rate_note": "在视图右下角显示每秒帧数。开启时视图会持续重绘。", "settings.measure_axis_distances": "显示 X/Y/Z 距离", "settings.measure_axis_distances_help": "测量对话框打开时,在总距离之外显示沿世界坐标轴方向的尺寸线和距离。", "settings.msaa_applies_on_restart": "抗锯齿将在下次启动 SolidCraft 时生效", @@ -1052,6 +1053,7 @@ "ui.flat": "平面", "ui.flip": "翻转", "ui.format": "格式", + "ui.frame_rate": "帧率", "ui.front": "前视", "ui.fully_constrained": "完全约束", "ui.general": "常规", diff --git a/crates/ui-egui/src/frame_rate.rs b/crates/ui-egui/src/frame_rate.rs new file mode 100644 index 00000000..523cdc08 --- /dev/null +++ b/crates/ui-egui/src/frame_rate.rs @@ -0,0 +1,96 @@ +//! The viewport's frame-rate readout (Preferences ▸ Graphics ▸ "Frame rate"). + +use egui::{FontId, Rect, Stroke, pos2, vec2}; + +use crate::SolveApp; +use crate::theme::Tokens; + +/// Weight of the newest frame in the running mean (roughly the last 20 frames count). +const NEWEST: f64 = 0.05; + +/// Frames per second, averaged over recent frames. +#[derive(Clone, Copy, Debug, Default)] +pub struct FrameRate { + mean_dt: f64, +} + +impl FrameRate { + /// Takes the time since the previous frame, in seconds. A gap over a second (the app idle + /// before the readout was turned on, a window being moved) is not a frame of a running view + /// and is left out, as is anything that isn't a positive finite time. + pub fn record(&mut self, dt: f64) { + if !dt.is_finite() || dt <= 0.0 || dt > 1.0 { + return; + } + self.mean_dt = if self.mean_dt > 0.0 { self.mean_dt + NEWEST * (dt - self.mean_dt) } else { dt }; + } + + /// The averaged rate; `None` before the first frame was recorded. + pub fn fps(&self) -> Option { + (self.mean_dt > 0.0).then(|| 1.0 / self.mean_dt) + } + + /// The readout's text: rate and the matching time per frame. + pub fn label(&self) -> Option { + self.fps().map(|fps| format!("{fps:.0} FPS · {:.1} ms", 1000.0 * self.mean_dt)) + } +} + +/// Records this frame and paints the readout in the view's bottom-right corner. While it is on +/// the view redraws every frame, so the rate is what the view can draw rather than how often +/// something happened to change. +pub(crate) fn paint(app: &mut SolveApp, ctx: &egui::Context, painter: &egui::Painter, rect: Rect) { + if !app.preferences.show_frame_rate { + app.frame_rate = FrameRate::default(); + return; + } + app.frame_rate.record(f64::from(ctx.input(|i| i.unstable_dt))); + ctx.request_repaint(); + let Some(text) = app.frame_rate.label() else { return }; + let t = Tokens::get(); + let font = FontId::monospace(12.0); + let galley = painter.layout_no_wrap(text, font, t.text); + let size = galley.size() + vec2(16.0, 8.0); + let r = Rect::from_min_size(pos2(rect.right() - 12.0 - size.x, rect.bottom() - 12.0 - size.y), size); + painter.rect(r, 4.0, t.overlay, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + crate::scenario::publish_handle("viewport:frame_rate", r.center()); + painter.galley(r.center() - galley.size() / 2.0, galley, t.text); +} + +#[cfg(test)] +mod tests { + use super::FrameRate; + + #[test] + fn steady_frames_read_their_rate() { + let mut f = FrameRate::default(); + assert_eq!(f.fps(), None); + for _ in 0..200 { + f.record(1.0 / 60.0); + } + assert!((f.fps().unwrap_or(0.0) - 60.0).abs() < 1e-6); + assert_eq!(f.label().as_deref(), Some("60 FPS · 16.7 ms")); + } + + #[test] + fn pauses_and_hostile_times_are_left_out() { + let mut f = FrameRate::default(); + for dt in [0.0, -1.0, f64::NAN, f64::INFINITY, 5.0] { + f.record(dt); + } + assert_eq!(f.fps(), None); + f.record(0.01); + f.record(3.0); + assert!((f.fps().unwrap_or(0.0) - 100.0).abs() < 1e-6); + } + + #[test] + fn the_mean_follows_a_slower_view() { + let mut f = FrameRate::default(); + f.record(1.0 / 120.0); + for _ in 0..300 { + f.record(1.0 / 30.0); + } + assert!((f.fps().unwrap_or(0.0) - 30.0).abs() < 0.1); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e90c6e18..a740dd3b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -44,6 +44,7 @@ pub mod drag_snap; pub mod edit_clipboard; pub mod fonts; pub mod frame; +pub mod frame_rate; pub mod gizmo; pub mod gpu; pub mod help; @@ -353,6 +354,8 @@ pub struct SolveApp { /// [`SolveApp::set_wgpu`]. pub cpu_meshing: bool, pub frame_ms: f64, + /// The viewport's frame-rate readout ([`frame_rate`]). + pub frame_rate: frame_rate::FrameRate, pub synthetic: Vec, /// The modifiers of the synthetic press in progress (a Ctrl-drag keeps Ctrl while it moves). pub synthetic_mods: egui::Modifiers, @@ -446,6 +449,7 @@ impl SolveApp { integrated_titlebar: false, cpu_meshing: false, frame_ms: 0.0, + frame_rate: frame_rate::FrameRate::default(), synthetic: Vec::new(), synthetic_mods: egui::Modifiers::default(), control_rx: None, diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index a7c28b5f..9ed41836 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -283,6 +283,8 @@ pub struct Prefs { pub supersampling: f32, /// Supersample only while the view holds still (full speed while orbiting). pub supersampling_adaptive: bool, + /// Show the frame rate in the view's corner (the view then redraws every frame). + pub show_frame_rate: bool, /// Show world X/Y/Z component dimensions beside the total distance in Measure. pub measure_axis_distances: bool, /// Decimals shown for lengths and angles. @@ -400,6 +402,7 @@ impl Default for Prefs { display_quality: "normal".into(), supersampling: 1.0, supersampling_adaptive: true, + show_frame_rate: false, measure_axis_distances: true, length_decimals: 2, angle_decimals: 1, @@ -624,6 +627,7 @@ fn defaults(app: &mut SolveApp, page: usize) { p.display_quality = d.display_quality; p.supersampling = d.supersampling; p.supersampling_adaptive = d.supersampling_adaptive; + p.show_frame_rate = d.show_frame_rate; p.measure_axis_distances = d.measure_axis_distances; p.color_vision = d.color_vision; app.ui.visual_style = 0; @@ -1310,6 +1314,13 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { colour_vision_sample(ui, t); ui.end_row(); note(ui, app.tr("ui.colour_vision_note")); + // Last, so the colour vision section above keeps its place on the page. + ui.label(""); + ui.end_row(); + ui.label(app.tr("ui.frame_rate")); + ui.checkbox(&mut app.preferences.show_frame_rate, ""); + ui.end_row(); + note(ui, app.tr("settings.frame_rate_note")); } 5 => { ui.label(crate::i18n::tr_label("Project face edges into new sketches")); diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 72928841..f4344154 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -2237,6 +2237,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { painter.add(egui_wgpu::Callback::new_paint_callback(rect, cb)); } analysis_legend(app, &painter, rect); + crate::frame_rate::paint(app, ui.ctx(), &painter, rect); // ---- interaction ---- let add = mods.shift || mods.command || mods.ctrl; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 3de543ff..2857b9ac 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -143,7 +143,7 @@ labels; `prefs:measure_axis_distances` identifies the checkbox. | `ui.home` | `open?`: bool; `sample?`: index | the start page: show or hide it, open a built-in sample (once built); returns `open` and the recent designs | | `ui.shortcut` | `command`, `key?`, `replace?` | a command's key, or rebinds it (`""` clears; a key another command uses is refused unless `replace`) | | `ui.help` | `item?`: `about`, `shortcuts` (read only), `report` (opens a report draft for review), `crashes` (the Crash Reports window) | the version, commit, build date and diagnostic text | -| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `blender`, `tinkercad`, `openscad`, `touchpad`), `zoom_reverse`, `zoom_to_cursor`, `smooth_wheel_zoom` (default true), `trackpad_pan` (default true: two-finger scroll pans, except Touchpad always zooms; pinch zooms), `orbit_around` (`view_centre` (default: what Pan moves and Fit frames), `origin` (the world origin stays still on screen), `cursor` (the model point under the pointer when the drag began); the older `orbit_cursor` boolean still sets `cursor` when true), `look_at_anim`, `look_at_speed`, `middle_double_click` (`home` (default), `fit`, `off`), `msaa`, `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system`), `language` (`en`, `ja`, `de`, `fr`, `ko`, `ru`, `uk`, `zh`; see [localization](localization.md)), `ui_scale`, `ui_font` (an installed font family or a font file for the interface; empty for the built-in font), `up_axis` (`z`, `y`), `offer_stand_up`, `color_vision` (`off` (default), `protan`, `deutan`, `tritan`: colour vision modes, #778), `check_updates` (default true; [updates](book/src/guide/updates.md)); `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection, AI) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` | +| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `blender`, `tinkercad`, `openscad`, `touchpad`), `zoom_reverse`, `zoom_to_cursor`, `smooth_wheel_zoom` (default true), `trackpad_pan` (default true: two-finger scroll pans, except Touchpad always zooms; pinch zooms), `orbit_around` (`view_centre` (default: what Pan moves and Fit frames), `origin` (the world origin stays still on screen), `cursor` (the model point under the pointer when the drag began); the older `orbit_cursor` boolean still sets `cursor` when true), `look_at_anim`, `look_at_speed`, `middle_double_click` (`home` (default), `fit`, `off`), `msaa`, `show_frame_rate` (default false: frames per second in the view's bottom-right corner; the view redraws every frame while it is on), `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system`), `language` (`en`, `ja`, `de`, `fr`, `ko`, `ru`, `uk`, `zh`; see [localization](localization.md)), `ui_scale`, `ui_font` (an installed font family or a font file for the interface; empty for the built-in font), `up_axis` (`z`, `y`), `offer_stand_up`, `color_vision` (`off` (default), `protan`, `deutan`, `tritan`: colour vision modes, #778), `check_updates` (default true; [updates](book/src/guide/updates.md)); `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection, AI) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` | | `ui.capture` | `action?`: `capture`, `revert`, `cancel` | answers the Capture Position question (asked when a command starts or the design is saved while components were moved without capturing); without `action`, what waits | | `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar); close asks about unsaved designs first | | `ui.agent_cursor` | `show` (bool), `speed`: a number from 1 to 5 (times the normal speed) or `"instant"` (no animation: the command runs at once and the pointer jumps to where it acted), `follow_camera` (bool); all optional | the settings. While the cursor is shown, `engine.execute` first glides to the command's toolbar button and to each pick, then runs and replies; later requests wait their turn. `"animate": false` on a call skips it |