From 8f768b65c801b255e48fd5892b3c50c136fa0992 Mon Sep 17 00:00:00 2001 From: cronos Date: Sun, 18 Oct 2026 15:34:05 +0900 Subject: [PATCH] Avoid per-frame mesh copies in both timeline layouts (#1907) Thanks @crono2250. --- crates/ui-egui/src/timeline.rs | 58 +++++++++++++++++++++++++---- crates/ui-egui/src/timeline_left.rs | 22 +++++------ 2 files changed, 61 insertions(+), 19 deletions(-) diff --git a/crates/ui-egui/src/timeline.rs b/crates/ui-egui/src/timeline.rs index a29e7dce..82940ec6 100644 --- a/crates/ui-egui/src/timeline.rs +++ b/crates/ui-egui/src/timeline.rs @@ -4,13 +4,39 @@ pub(crate) use crate::icons::glyphs::transport_glyph; use egui::{Rect, Sense, Stroke, UiBuilder, pos2, vec2}; use serde_json::json; use solidcraft_engine::doc::{ - FeatureKind, PlaneRef, + FeatureKind, PlaneRef, SketchId, construct::{AxisDef, PointDef}, }; use crate::theme::Tokens; use crate::{SolveApp, icons}; +// Snapshot only the data used by timeline interactions. Cloning FeatureKind here would +// also copy imported mesh vertices and triangles on every frame. +#[derive(Clone, Copy)] +pub(crate) enum TimelineKind { + Sketch, + Profile(SketchId), + Other, +} + +impl TimelineKind { + pub(crate) fn of(kind: &FeatureKind) -> Self { + match kind { + FeatureKind::Sketch { .. } => Self::Sketch, + FeatureKind::Extrude { sketch, .. } | FeatureKind::Revolve { sketch, .. } | FeatureKind::Sweep { sketch, .. } => Self::Profile(*sketch), + _ => Self::Other, + } + } + + pub(crate) fn profile_sketch(self) -> Option { + match self { + Self::Profile(sketch) => Some(sketch), + _ => None, + } + } +} + pub(crate) fn icon_of(k: &FeatureKind) -> &'static str { match k { FeatureKind::Sketch { .. } => "sketch", @@ -263,7 +289,7 @@ pub fn timeline(app: &mut SolveApp, ui: &mut egui::Ui) { .doc .features .iter() - .map(|f| (f.id, f.name.clone(), icon_of(&f.kind), f.suppressed, f.kind.type_name(), f.kind.clone())) + .map(|f| (f.id, f.name.clone(), icon_of(&f.kind), f.suppressed, f.kind.type_name(), TimelineKind::of(&f.kind))) .collect(); // The feature whose body is under the cursor in the viewport. let st = app.session.world_state(); @@ -373,12 +399,7 @@ pub fn timeline(app: &mut SolveApp, ui: &mut egui::Ui) { if resp.double_clicked() { edit = Some(*id); } - let sketch_of = match kind { - FeatureKind::Extrude { sketch, .. } | FeatureKind::Revolve { sketch, .. } | FeatureKind::Sweep { sketch, .. } => { - Some(*sketch) - } - _ => None, - }; + let sketch_of = kind.profile_sketch(); resp.context_menu(|ui| { if ui.button(crate::i18n::tr_label("Edit Feature")).clicked() { edit = Some(*id); @@ -596,6 +617,27 @@ mod tests { h } + #[test] + fn the_feature_menu_edits_its_profile_sketch_in_both_timelines() { + for left in [false, true] { + let mut h = Harness::new(); + h.app.preferences.timeline_left = left; + h.app.run("sketch.create", json!({"plane": "XY", "name": "Base"})).unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + h.app.run("solid.extrude", json!({"sketch": "Base", "distance": 10, "operation": "new", "name": "Depth"})).unwrap(); + let sketch = h.app.session.doc.find_feature("Base").map(|f| f.id).unwrap(); + h.frames(4); + + h.step(&json!({"click": {"handle": "timeline:Base"}, "button": "right"})).unwrap(); + assert!(h.widget("Edit Profile Sketch").is_none(), "left: {left}"); + h.step(&json!({"key": "Escape"})).unwrap(); + h.step(&json!({"click": {"handle": "timeline:Depth"}, "button": "right"})).unwrap(); + h.step(&json!({"widget": "Edit Profile Sketch"})).unwrap(); + assert_eq!(h.app.session.active_sketch, Some(sketch), "left: {left}"); + } + } + fn copied(h: &Harness) -> Vec { h.app.session.clipboard.iter().map(|f| f.name.clone()).collect() } diff --git a/crates/ui-egui/src/timeline_left.rs b/crates/ui-egui/src/timeline_left.rs index 3f88b81a..fb70bbf7 100644 --- a/crates/ui-egui/src/timeline_left.rs +++ b/crates/ui-egui/src/timeline_left.rs @@ -2,10 +2,11 @@ use egui::{Color32, Rect, RichText, Sense, Stroke, pos2, vec2}; use serde_json::json; -use solidcraft_engine::doc::FeatureKind; use crate::theme::Tokens; -use crate::timeline::{clipboard_buttons, delete_feature, delete_features, icon_of, message_button, selected_feature_ids, transport_glyph}; +use crate::timeline::{ + TimelineKind, clipboard_buttons, delete_feature, delete_features, icon_of, message_button, selected_feature_ids, transport_glyph, +}; use crate::{SolveApp, icons}; const FOLDER_INDENT: f32 = 14.0; @@ -427,7 +428,7 @@ fn group_ancestors_are_open(app: &SolveApp, groups: &[solidcraft_engine::doc::Ti fn group_first_feature( groups: &[solidcraft_engine::doc::TimelineGroup], - features: &[(u64, String, &'static str, bool, &'static str, FeatureKind)], + features: &[(u64, String, &'static str, bool, &'static str, TimelineKind)], id: u64, ) -> Option { let group = groups.iter().find(|group| group.id == id)?; @@ -438,7 +439,7 @@ fn group_first_feature( fn group_last_feature( groups: &[solidcraft_engine::doc::TimelineGroup], - features: &[(u64, String, &'static str, bool, &'static str, FeatureKind)], + features: &[(u64, String, &'static str, bool, &'static str, TimelineKind)], id: u64, ) -> Option { let group = groups.iter().find(|group| group.id == id)?; @@ -449,7 +450,7 @@ fn group_last_feature( fn visible_order( app: &SolveApp, - features: &[(u64, String, &'static str, bool, &'static str, FeatureKind)], + features: &[(u64, String, &'static str, bool, &'static str, TimelineKind)], groups: &[solidcraft_engine::doc::TimelineGroup], ) -> Vec { let mut order = Vec::new(); @@ -615,7 +616,9 @@ fn vertical_features(app: &mut SolveApp, ui: &mut egui::Ui) { .doc .features .iter() - .map(|feature| (feature.id, feature.name.clone(), icon_of(&feature.kind), feature.suppressed, feature.kind.type_name(), feature.kind.clone())) + .map(|feature| { + (feature.id, feature.name.clone(), icon_of(&feature.kind), feature.suppressed, feature.kind.type_name(), TimelineKind::of(&feature.kind)) + }) .collect(); let groups = app.session.doc.timeline_groups.clone(); let order = visible_order(app, &features, &groups); @@ -685,7 +688,7 @@ fn vertical_features(app: &mut SolveApp, ui: &mut egui::Ui) { } slots.push(VerticalSlot { y: ui.cursor().top(), position: index, group: group_id, indent_group }); let indent = group_id.map(|id| (group_depth(&groups, id) + 1) as f32 * 14.0).unwrap_or(0.0); - let has_patterns = matches!(kind, FeatureKind::Sketch { .. }) && crate::sketch_pattern_tree::has_patterns(app, *id); + let has_patterns = matches!(kind, TimelineKind::Sketch) && crate::sketch_pattern_tree::has_patterns(app, *id); let pattern_key = format!("timeline/sketch/{id}/patterns"); let patterns_open = crate::browser::is_open(app, &pattern_key, true); let (rect, response) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click_and_drag()); @@ -771,10 +774,7 @@ fn vertical_features(app: &mut SolveApp, ui: &mut egui::Ui) { if response.double_clicked() && !on_fold { edit = Some(*id); } - let sketch = match kind { - FeatureKind::Extrude { sketch, .. } | FeatureKind::Revolve { sketch, .. } | FeatureKind::Sweep { sketch, .. } => Some(*sketch), - _ => None, - }; + let sketch = kind.profile_sketch(); if response.secondary_clicked() && !selected { select_only(app, TimelineItem::Feature(*id)); }