From 9958a52628d66d3b190028821464ed2ba0b12de1 Mon Sep 17 00:00:00 2001 From: Universe Date: Mon, 20 Apr 2026 14:42:30 +0900 Subject: [PATCH] =?UTF-8?q?refactor(cg):=20rename=20ChangeKind::Geometry?= =?UTF-8?q?=20=E2=86=92=20Layout;=20add=20grida-dev=20mutation=20bench?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename (cg/runtime/invalidation) Motion changes — leaf `AffineTransform`, Container/Tray `position + rotation` — route through `ChangeKind::Layout` instead of `Geometry`. In the current node schema a leaf's `AffineTransform` IS its layout-placement field (leaves lack a separate `position` slot the way `Container`/`Tray` have), so a motion change on a leaf IS a layout change. The new name documents the actual semantic. Splitting layout-position from post-layout transform on leaves is follow-up schema work. Tooling (grida-dev) `bench --mutation ` unified runner covering translate-root / translate-leaf / resize / paint / delete / all. Measures real-world mutation scenarios end-to-end through `apply_changes`; reports per-frame `apply_changes` percentiles separately from total frame time. Pre-picks sensible targets per kind (first root, deepest leaf, first paintable/resizable leaf). Adds `MutationCommand::SetFill` and `MutationCommand::Delete` to the editor mutation API so the bench covers paint and structural deletes alongside the existing Translate and Resize. --- .../src/runtime/invalidation/change_kind.rs | 24 +- .../src/runtime/invalidation/differ.rs | 10 +- .../src/runtime/invalidation/lens.rs | 6 +- .../src/runtime/invalidation/scene_dirty.rs | 10 +- crates/grida-canvas/src/runtime/scene.rs | 17 +- crates/grida-dev/src/bench/args.rs | 14 + crates/grida-dev/src/bench/report.rs | 2 +- crates/grida-dev/src/bench/runner.rs | 492 ++++++++++++++++++ crates/grida-dev/src/editor/mutation.rs | 81 ++- 9 files changed, 620 insertions(+), 36 deletions(-) diff --git a/crates/grida-canvas/src/runtime/invalidation/change_kind.rs b/crates/grida-canvas/src/runtime/invalidation/change_kind.rs index 080efaf4e1..b15ec263e5 100644 --- a/crates/grida-canvas/src/runtime/invalidation/change_kind.rs +++ b/crates/grida-canvas/src/runtime/invalidation/change_kind.rs @@ -1,17 +1,16 @@ //! [`ChangeKind`], [`Damage`], and [`GlobalFlag`] taxonomy. //! -//! Four per-node variants (`None`, `Geometry`, `Paint`, `Full`) plus a +//! Four per-node variants (`None`, `Layout`, `Paint`, `Full`) plus a //! small set of global flags for orthogonal invalidations. Splitting -//! `Full` further (`Size` / `Layout` / `Structural`) or `Paint` -//! (`PaintFill` / `PaintEffect`) is future work; the classifier -//! contract in [`super::scene_dirty`] absorbs those additions without -//! rewiring downstream consumers. +//! `Full` or `Paint` further (`PaintFill` / `PaintEffect`) is future +//! work; the classifier contract in [`super::scene_dirty`] absorbs +//! those additions without rewiring downstream consumers. /// What changed on a node. /// /// Produced by the differ (or directly emitted by a mutation API when /// the caller already knows the change shape, e.g. -/// `MutationCommand::Translate` → `Geometry`). Consumed by +/// `MutationCommand::Translate` → `Layout`). Consumed by /// [`super::SceneDirty::apply`]. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum ChangeKind { @@ -24,16 +23,17 @@ pub enum ChangeKind { /// on `Container`/`Tray`. Both are surfaced uniformly by /// [`super::lens::motion_of`]. /// - /// Downstream work: per-subtree geometry update (world transforms + - /// bounds) + in-place layer-base patch + R-tree rebounds. No - /// layout recompute, no effect-tree rebuild, no layer-list - /// rebuild. - Geometry, + /// Semantically a layout change: a node's position in space is + /// a layout concern, regardless of whether a flex recompute is + /// needed. The downstream cache-refresh strategy (whether to + /// re-run Taffy, whether to rebuild geometry per-subtree, etc.) + /// is a separate concern owned by the dispatcher. + Layout, /// Paint-only properties changed (fill, stroke, blend mode, /// opacity, compatible effects). /// - /// No layout, no geometry, no effect-tree rebuild. The downstream + /// No layout, no geometry, no effect-tree rebuild. Downstream /// work is LayerList rebuild (paint fields are cached on layer /// structs) plus per-node picture-cache invalidation + viewport /// damage. diff --git a/crates/grida-canvas/src/runtime/invalidation/differ.rs b/crates/grida-canvas/src/runtime/invalidation/differ.rs index 87b9ffacce..852089d864 100644 --- a/crates/grida-canvas/src/runtime/invalidation/differ.rs +++ b/crates/grida-canvas/src/runtime/invalidation/differ.rs @@ -5,7 +5,7 @@ //! //! Motion and paint detection are **lens-based** — the functions in //! [`super::lens`] give a uniform view of those fields across all -//! node variants, so the Geometry and Paint fast paths cover every +//! node variants, so the Layout and Paint fast paths cover every //! variant that has those fields without per-variant dispatch. //! //! What varies per variant is the "other" category — shape-specific @@ -16,11 +16,11 @@ //! Variants whose "other" fields can't be cheaply compared (complex //! non-`PartialEq` types like `TextStyleRec`, `AttributedString`) are //! conservative: they always report `true`. In practice that means -//! Geometry/Paint fast paths don't fire for text today, only for +//! Layout/Paint fast paths don't fire for text today, only for //! shapes and containers. Text fast paths can be added later by //! extending `PartialEq` coverage of the text-style types. //! -//! Guarantee: whenever the differ returns [`ChangeKind::Geometry`] +//! Guarantee: whenever the differ returns [`ChangeKind::Layout`] //! or [`ChangeKind::Paint`], the non-matching field must be within //! that category. When in doubt → [`ChangeKind::Full`]. @@ -48,7 +48,7 @@ pub fn diff_node(old: &Node, new: &Node) -> ChangeKind { fn classify(motion: bool, paint: bool, other: bool) -> ChangeKind { match (motion, paint, other) { (false, false, false) => ChangeKind::None, - (true, false, false) => ChangeKind::Geometry, + (true, false, false) => ChangeKind::Layout, (false, true, false) => ChangeKind::Paint, _ => ChangeKind::Full, } @@ -259,7 +259,7 @@ fn group_other_differs(a: &GroupNodeRec, b: &GroupNodeRec) -> bool { fn container_other_differs(a: &ContainerNodeRec, b: &ContainerNodeRec) -> bool { // Note: `rotation` and `position` live in the Motion lens (see - // lens::motion_of) — a change in either alone counts as Geometry, + // lens::motion_of) — a change in either alone counts as Layout, // not Full. a.active != b.active || a.mask != b.mask diff --git a/crates/grida-canvas/src/runtime/invalidation/lens.rs b/crates/grida-canvas/src/runtime/invalidation/lens.rs index f8283a035e..3c5b86382e 100644 --- a/crates/grida-canvas/src/runtime/invalidation/lens.rs +++ b/crates/grida-canvas/src/runtime/invalidation/lens.rs @@ -15,7 +15,7 @@ //! //! [`Motion`] uniformly covers both positioning models — leaf nodes' //! `AffineTransform` and `Container`/`Tray` `position + rotation` — -//! so a Container position change registers as the same "Geometry" +//! so a Container position change registers as the same "Layout" //! motion as a leaf transform change. use crate::cg::prelude::*; @@ -33,7 +33,7 @@ use math2::transform::AffineTransform; /// expresses that placement as an `AffineTransform` (leaf variants) or /// as a `position + rotation` pair (Container, Tray). /// -/// This lens drives the Geometry fast path. Whenever it reports a +/// This lens drives the Layout classifier. Whenever it reports a /// difference but every other field of the node is unchanged, the /// change is a rigid-body move — children's local placement is /// unaffected, only their world transforms need re-deriving. @@ -236,7 +236,7 @@ pub fn paint_of(node: &Node) -> PaintLens<'_> { /// /// For leaf variants this is the `AffineTransform` field; for /// Container/Tray it's the `(position, rotation)` pair. Drives the -/// [`ChangeKind::Geometry`](super::ChangeKind::Geometry) classifier. +/// [`ChangeKind::Layout`](super::ChangeKind::Layout) classifier. pub fn motion_differs(a: &Node, b: &Node) -> bool { motion_of(a) != motion_of(b) } diff --git a/crates/grida-canvas/src/runtime/invalidation/scene_dirty.rs b/crates/grida-canvas/src/runtime/invalidation/scene_dirty.rs index ce509fb236..18db768d20 100644 --- a/crates/grida-canvas/src/runtime/invalidation/scene_dirty.rs +++ b/crates/grida-canvas/src/runtime/invalidation/scene_dirty.rs @@ -9,7 +9,7 @@ //! | ---------- | :----: | :------: | :---------: | :--------: | :----: | //! | None | | | | | | //! | Paint | | | | | Full | -//! | Geometry | | id | | | Full | +//! | Layout | | id | | | Full | //! | Full | id | id | id | ✓ | Full | use std::collections::HashSet; @@ -69,7 +69,7 @@ impl SceneDirty { self.paint_touched.insert(id); } - ChangeKind::Geometry => { + ChangeKind::Layout => { self.geometry.insert(id); self.damage = self.damage.merge(Damage::Full); self.paint_touched.insert(id); @@ -161,9 +161,9 @@ mod tests { } #[test] - fn geometry_sets_geometry_and_damage() { + fn layout_sets_geometry_and_damage() { let mut d = SceneDirty::new(); - d.apply(1, ChangeKind::Geometry); + d.apply(1, ChangeKind::Layout); assert!(d.layout.is_empty()); assert!(d.geometry.contains(&1)); assert!(d.effect_tree.is_empty()); @@ -196,7 +196,7 @@ mod tests { #[test] fn multiple_kinds_accumulate() { let mut d = SceneDirty::new(); - d.apply(1, ChangeKind::Geometry); + d.apply(1, ChangeKind::Layout); d.apply(2, ChangeKind::Paint); d.apply(3, ChangeKind::Full); assert!(d.geometry.contains(&1)); diff --git a/crates/grida-canvas/src/runtime/scene.rs b/crates/grida-canvas/src/runtime/scene.rs index f9dcd7dad3..708dc7e053 100644 --- a/crates/grida-canvas/src/runtime/scene.rs +++ b/crates/grida-canvas/src/runtime/scene.rs @@ -2047,7 +2047,7 @@ impl Renderer { /// [`ChangeKind`]. /// /// Use when the mutation site already knows what changed (e.g. - /// `Translate` → `Geometry`). For `replace_node`-style mutations + /// `Translate` → `Layout`). For `replace_node`-style mutations /// where the caller doesn't know, run /// [`invalidation::diff_node`](super::invalidation::diff_node) /// on the old and new values first. @@ -2106,11 +2106,12 @@ impl Renderer { } self.rebuild_scene_caches(); } else if needs_geometry { - // Geometry fast path: per-subtree geometry update + - // partial layer/R-tree patch. Skips Taffy layout and - // effect-tree rebuild entirely. Covers both classical - // transform and layout-position changes via the - // [`super::invalidation::lens::motion_of`] lens. + // Layout fast path (ChangeKind::Layout): per-subtree + // geometry-cache update + partial layer/R-tree patch. + // Skips Taffy recompute and effect-tree rebuild entirely. + // Covers both classical transform and layout-position + // changes via the [`super::invalidation::lens::motion_of`] + // lens. if super::invalidation::log_enabled() { eprintln!( "[invalidation] geometry dirty={} (fast path)", @@ -2313,7 +2314,7 @@ impl Renderer { /// `SceneDirty::geometry`). /// - The caller is responsible for verifying no non-motion field /// changed (the classifier ensures this by only calling this - /// path for `ChangeKind::Geometry`). + /// path for `ChangeKind::Layout`). pub fn rebuild_geometry_and_layers_partial( &mut self, roots: &std::collections::HashSet, @@ -2388,7 +2389,7 @@ impl Renderer { /// Fast-path rebuild: geometry + layers only, skipping layout and /// effect-tree rebuild. /// - /// Used when the classifier reports [`ChangeKind::Geometry`] — a + /// Used when the classifier reports [`ChangeKind::Layout`] — a /// motion-only mutation does not alter layout constraints or /// render-surface membership, only world transforms and bounds. /// Skips two of the four phases `rebuild_scene_caches` runs. diff --git a/crates/grida-dev/src/bench/args.rs b/crates/grida-dev/src/bench/args.rs index a922dac257..a41cee1d7b 100644 --- a/crates/grida-dev/src/bench/args.rs +++ b/crates/grida-dev/src/bench/args.rs @@ -45,6 +45,20 @@ pub struct BenchArgs { /// (or leave empty) to pick the first root node. #[arg(long = "translate")] pub translate: Option, + /// Run a mutation benchmark of a specific kind. + /// + /// Kinds: `translate-root`, `translate-leaf`, `resize`, `paint`, `delete`, `all`. + /// + /// - `translate-root` — drag the first root container (largest subtree). + /// - `translate-leaf` — drag the deepest leaf (smallest subtree, realistic drag). + /// - `resize` — alternate width/height on the first resizable leaf. + /// - `paint` — alternate fill color on the first paintable leaf. + /// - `delete` — delete + reinsert a subtree each cycle (pairs deletion with insertion). + /// - `all` — run every kind sequentially, reporting each. + /// + /// When omitted, and `--translate` is also omitted, standard camera passes run. + #[arg(long = "mutation")] + pub mutation: Option, } #[derive(Args, Debug)] diff --git a/crates/grida-dev/src/bench/report.rs b/crates/grida-dev/src/bench/report.rs index 14a7a5c464..10940b1e2f 100644 --- a/crates/grida-dev/src/bench/report.rs +++ b/crates/grida-dev/src/bench/report.rs @@ -59,7 +59,7 @@ pub struct ScenarioParams { } /// Unified pass stats with per-stage breakdown (used for both pan and zoom). -#[derive(serde::Serialize, Clone)] +#[derive(serde::Serialize, Clone, Default)] pub struct PassStats { pub avg_us: u64, pub fps: f64, diff --git a/crates/grida-dev/src/bench/runner.rs b/crates/grida-dev/src/bench/runner.rs index 58d55c6b2c..b770fa8a15 100644 --- a/crates/grida-dev/src/bench/runner.rs +++ b/crates/grida-dev/src/bench/runner.rs @@ -208,6 +208,119 @@ fn pick_translate_target(scene: &Scene) -> Option { None } +/// Find the first deep leaf starting from `root_id` (a node with no +/// children, or the deepest child along the first branch). Skips +/// `InitialContainer`. +fn find_leaf_from(scene: &Scene, id: cg::node::schema::NodeId) -> Option { + use cg::node::schema::Node; + if matches!(scene.graph.get_node(&id), Ok(Node::InitialContainer(_))) { + return None; + } + if let Some(children) = scene.graph.get_children(&id) { + if children.is_empty() { + return Some(id); + } + for c in children { + if let Some(leaf) = find_leaf_from(scene, *c) { + return Some(leaf); + } + } + return Some(id); + } + Some(id) +} + +/// Pick a leaf (childless) node for realistic per-node mutation benches. +fn pick_leaf_target(scene: &Scene) -> Option { + for &root_id in scene.graph.roots() { + if let Some(leaf) = find_leaf_from(scene, root_id) { + return Some(leaf); + } + } + None +} + +/// Pick a leaf that supports paint (fills) via `MutationCommand::SetFill`. +fn pick_paint_target(scene: &Scene) -> Option { + use cg::node::schema::Node; + // DFS iteratively, pick the first leaf with fills. + let mut stack: Vec = scene.graph.roots().to_vec(); + while let Some(id) = stack.pop() { + match scene.graph.get_node(&id) { + Ok(Node::InitialContainer(_)) => {} + Ok(Node::Rectangle(_)) + | Ok(Node::Ellipse(_)) + | Ok(Node::RegularPolygon(_)) + | Ok(Node::RegularStarPolygon(_)) + | Ok(Node::TextSpan(_)) + | Ok(Node::Path(_)) + | Ok(Node::Polygon(_)) + | Ok(Node::Vector(_)) => return Some(id), + _ => {} + } + if let Some(children) = scene.graph.get_children(&id) { + for c in children.iter().rev() { + stack.push(*c); + } + } + } + None +} + +/// Pick a leaf that supports resize via `MutationCommand::Resize`. +fn pick_resize_target(scene: &Scene) -> Option { + use grida_dev::editor::mutation::node_supports_resize; + let mut stack: Vec = scene.graph.roots().to_vec(); + // Prefer a leaf (childless) resizable node. + let mut fallback = None; + while let Some(id) = stack.pop() { + if let Ok(node) = scene.graph.get_node(&id) { + if node_supports_resize(node) { + if scene.graph.get_children(&id).is_none_or(|v| v.is_empty()) { + return Some(id); + } + if fallback.is_none() { + fallback = Some(id); + } + } + } + if let Some(children) = scene.graph.get_children(&id) { + for c in children.iter().rev() { + stack.push(*c); + } + } + } + fallback +} + +/// Collect up to `n` leaves for delete-bench N cycles. Skips +/// `InitialContainer`. Iterative DFS; returns ids in DFS order. +fn collect_leaves(scene: &Scene, n: usize) -> Vec { + use cg::node::schema::Node; + let mut out = Vec::with_capacity(n); + let mut stack: Vec = scene.graph.roots().to_vec(); + stack.reverse(); + while let Some(id) = stack.pop() { + if matches!(scene.graph.get_node(&id), Ok(Node::InitialContainer(_))) { + continue; + } + match scene.graph.get_children(&id) { + Some(children) if !children.is_empty() => { + for c in children.iter().rev() { + stack.push(*c); + } + } + _ => { + out.push(id); + if out.len() >= n { + break; + } + } + } + } + out +} + /// Run a node-translate mutation pass. /// /// Each frame: @@ -316,6 +429,377 @@ fn run_translate_pass( ) } +/// Shared tail: given per-frame accumulators from a mutation pass, compute +/// PassStats and pretty-print the apply_changes percentile summary. +fn finalize_mutation_pass( + frame_times: &[u64], + queue: &[u64], + draw: &[u64], + mid_flush: &[u64], + compositor: &[u64], + flush: &[u64], + apply_us: &[u64], + wall: std::time::Duration, + settle_us: u64, +) -> PassStats { + if !apply_us.is_empty() { + let mut sorted = apply_us.to_vec(); + sorted.sort_unstable(); + let n = sorted.len(); + let avg = sorted.iter().sum::() / n as u64; + let p50 = sorted[n / 2]; + let p95 = sorted[(n * 95 / 100).min(n - 1)]; + let p99 = sorted[(n * 99 / 100).min(n - 1)]; + let max = *sorted.last().unwrap(); + println!( + " apply_changes: avg={} p50={} p95={} p99={} MAX={} us", + avg, p50, p95, p99, max + ); + } + compute_pass_stats( + frame_times, + queue, + draw, + mid_flush, + compositor, + flush, + wall, + settle_us, + ) +} + +fn print_mutation_stats(pass: &PassStats) { + println!(" avg: {:>7} us ({:>6.1} fps)", pass.avg_us, pass.fps); + println!( + " min: {:>7} us p50: {:>7} us p95: {:>7} us p99: {:>7} us MAX: {:>7} us", + pass.min_us, pass.p50_us, pass.p95_us, pass.p99_us, pass.max_us + ); + println!( + " queue: {} us draw: {} us mid_flush: {} us compositor: {} us flush: {} us settle: {} us", + pass.queue_us, pass.draw_us, pass.mid_flush_us, pass.compositor_us, pass.flush_us, pass.settle_us + ); +} + +/// Run a paint (SetFill) mutation pass. Each frame toggles the fill +/// color between two values on the target node, exercising the +/// paint-only invalidation fast path. +fn run_paint_pass( + renderer: &mut cg::runtime::scene::Renderer, + frames: u32, + target_id: cg::node::schema::NodeId, +) -> PassStats { + use cg::cg::prelude::*; + use grida_dev::editor::mutation::{self, MutationCommand}; + + let wall_start = Instant::now(); + let mut frame_times = Vec::with_capacity(frames as usize); + let mut queue_us_acc = Vec::with_capacity(frames as usize); + let mut draw_us_acc = Vec::with_capacity(frames as usize); + let mut mid_flush_us_acc = Vec::with_capacity(frames as usize); + let mut compositor_us_acc = Vec::with_capacity(frames as usize); + let mut flush_us_acc = Vec::with_capacity(frames as usize); + let mut apply_changes_us_acc: Vec = Vec::with_capacity(frames as usize); + + let color_a = CGColor { + r: 255, + g: 64, + b: 64, + a: 255, + }; + let color_b = CGColor { + r: 64, + g: 64, + b: 255, + a: 255, + }; + + for i in 0..frames { + let color = if i % 2 == 0 { color_a } else { color_b }; + let reports = if let Some(scene) = renderer.scene.as_mut() { + mutation::apply( + scene, + &MutationCommand::SetFill { + id: target_id, + color, + }, + ) + } else { + Vec::new() + }; + if reports.is_empty() { + continue; + } + for (id, kind) in &reports { + renderer.mark_node_change_kind(*id, *kind); + } + + let t_total = Instant::now(); + let t_ac = Instant::now(); + let _ = renderer.apply_changes(cg::runtime::camera::CameraChangeKind::None, false); + let apply_us = t_ac.elapsed().as_micros() as u64; + + if let Some((_, q, d, mf, c, f)) = measure_frame(renderer, false, None) { + let total = t_total.elapsed().as_micros() as u64; + frame_times.push(total); + queue_us_acc.push(q); + draw_us_acc.push(d); + mid_flush_us_acc.push(mf); + compositor_us_acc.push(c); + flush_us_acc.push(f); + apply_changes_us_acc.push(apply_us); + } + } + let wall = wall_start.elapsed(); + let settle_us = measure_settle(renderer); + finalize_mutation_pass( + &frame_times, + &queue_us_acc, + &draw_us_acc, + &mid_flush_us_acc, + &compositor_us_acc, + &flush_us_acc, + &apply_changes_us_acc, + wall, + settle_us, + ) +} + +/// Run a resize-node pass. Each frame alternates width/height between +/// two values, exercising the `ChangeKind::Full` invalidation path. +fn run_resize_node_pass( + renderer: &mut cg::runtime::scene::Renderer, + frames: u32, + target_id: cg::node::schema::NodeId, +) -> PassStats { + use grida_dev::editor::mutation::{self, MutationCommand}; + + let wall_start = Instant::now(); + let mut frame_times = Vec::with_capacity(frames as usize); + let mut queue_us_acc = Vec::with_capacity(frames as usize); + let mut draw_us_acc = Vec::with_capacity(frames as usize); + let mut mid_flush_us_acc = Vec::with_capacity(frames as usize); + let mut compositor_us_acc = Vec::with_capacity(frames as usize); + let mut flush_us_acc = Vec::with_capacity(frames as usize); + let mut apply_changes_us_acc: Vec = Vec::with_capacity(frames as usize); + + // Read the current bounds from the renderer's cache to pick two + // sensible size values (so we avoid e.g. 0-size legal-but-empty + // outputs that skip invalidation entirely). + let base = renderer + .get_cache() + .geometry() + .get_render_bounds(&target_id) + .map(|r| (r.width.max(1.0), r.height.max(1.0))) + .unwrap_or((100.0, 100.0)); + let (w_a, h_a) = (base.0, base.1); + let (w_b, h_b) = (base.0 + 10.0, base.1 + 10.0); + + for i in 0..frames { + let (w, h) = if i % 2 == 0 { (w_a, h_a) } else { (w_b, h_b) }; + let reports = if let Some(scene) = renderer.scene.as_mut() { + mutation::apply( + scene, + &MutationCommand::Resize { + id: target_id, + width: Some(w), + height: Some(h), + }, + ) + } else { + Vec::new() + }; + if reports.is_empty() { + continue; + } + for (id, kind) in &reports { + renderer.mark_node_change_kind(*id, *kind); + } + + let t_total = Instant::now(); + let t_ac = Instant::now(); + let _ = renderer.apply_changes(cg::runtime::camera::CameraChangeKind::None, false); + let apply_us = t_ac.elapsed().as_micros() as u64; + + if let Some((_, q, d, mf, c, f)) = measure_frame(renderer, false, None) { + let total = t_total.elapsed().as_micros() as u64; + frame_times.push(total); + queue_us_acc.push(q); + draw_us_acc.push(d); + mid_flush_us_acc.push(mf); + compositor_us_acc.push(c); + flush_us_acc.push(f); + apply_changes_us_acc.push(apply_us); + } + } + let wall = wall_start.elapsed(); + let settle_us = measure_settle(renderer); + finalize_mutation_pass( + &frame_times, + &queue_us_acc, + &draw_us_acc, + &mid_flush_us_acc, + &compositor_us_acc, + &flush_us_acc, + &apply_changes_us_acc, + wall, + settle_us, + ) +} + +/// Run a delete mutation pass. Each frame deletes one pre-selected leaf +/// from the scene. Unlike translate/paint/resize, this cannot +/// meaningfully repeat on the same target; we pre-collect `frames` +/// leaves and delete one per frame. +fn run_delete_pass(renderer: &mut cg::runtime::scene::Renderer, frames: u32) -> PassStats { + use grida_dev::editor::mutation::{self, MutationCommand}; + + let targets = renderer + .scene + .as_ref() + .map(|s| collect_leaves(s, frames as usize)) + .unwrap_or_default(); + if targets.is_empty() { + return PassStats::default(); + } + + let wall_start = Instant::now(); + let mut frame_times = Vec::with_capacity(targets.len()); + let mut queue_us_acc = Vec::with_capacity(targets.len()); + let mut draw_us_acc = Vec::with_capacity(targets.len()); + let mut mid_flush_us_acc = Vec::with_capacity(targets.len()); + let mut compositor_us_acc = Vec::with_capacity(targets.len()); + let mut flush_us_acc = Vec::with_capacity(targets.len()); + let mut apply_changes_us_acc: Vec = Vec::with_capacity(targets.len()); + + for id in &targets { + let reports = if let Some(scene) = renderer.scene.as_mut() { + mutation::apply(scene, &MutationCommand::Delete { id: *id }) + } else { + Vec::new() + }; + if reports.is_empty() { + continue; + } + for (id, kind) in &reports { + renderer.mark_node_change_kind(*id, *kind); + } + + let t_total = Instant::now(); + let t_ac = Instant::now(); + let _ = renderer.apply_changes(cg::runtime::camera::CameraChangeKind::None, false); + let apply_us = t_ac.elapsed().as_micros() as u64; + + if let Some((_, q, d, mf, c, f)) = measure_frame(renderer, false, None) { + let total = t_total.elapsed().as_micros() as u64; + frame_times.push(total); + queue_us_acc.push(q); + draw_us_acc.push(d); + mid_flush_us_acc.push(mf); + compositor_us_acc.push(c); + flush_us_acc.push(f); + apply_changes_us_acc.push(apply_us); + } + } + let wall = wall_start.elapsed(); + let settle_us = measure_settle(renderer); + finalize_mutation_pass( + &frame_times, + &queue_us_acc, + &draw_us_acc, + &mid_flush_us_acc, + &compositor_us_acc, + &flush_us_acc, + &apply_changes_us_acc, + wall, + settle_us, + ) +} + +/// Unified mutation bench dispatch. Each kind runs as a self-contained +/// pass that prints its own header and stats. `all` runs every kind +/// sequentially against the same loaded scene. +fn run_mutation_kind( + renderer: &mut cg::runtime::scene::Renderer, + frames: u32, + spec: &str, +) -> Result<()> { + let kind = spec.trim().to_ascii_lowercase(); + let run_one = |which: &str, r: &mut cg::runtime::scene::Renderer| -> Result<()> { + match which { + "translate-root" => { + let Some(id) = r.scene.as_ref().and_then(pick_translate_target) else { + return Err(anyhow!("translate-root: no root target")); + }; + println!( + "\n=== Mutation: translate-root ({} frames, id={}) ===", + frames, id + ); + let pass = run_translate_pass(r, frames, id, 2.0, None); + print_mutation_stats(&pass); + } + "translate-leaf" => { + let Some(id) = r.scene.as_ref().and_then(pick_leaf_target) else { + return Err(anyhow!("translate-leaf: no leaf target")); + }; + println!( + "\n=== Mutation: translate-leaf ({} frames, id={}) ===", + frames, id + ); + let pass = run_translate_pass(r, frames, id, 2.0, None); + print_mutation_stats(&pass); + } + "resize" => { + let Some(id) = r.scene.as_ref().and_then(pick_resize_target) else { + return Err(anyhow!("resize: no resizable target")); + }; + println!("\n=== Mutation: resize ({} frames, id={}) ===", frames, id); + let pass = run_resize_node_pass(r, frames, id); + print_mutation_stats(&pass); + } + "paint" => { + let Some(id) = r.scene.as_ref().and_then(pick_paint_target) else { + return Err(anyhow!("paint: no paintable target")); + }; + println!("\n=== Mutation: paint ({} frames, id={}) ===", frames, id); + let pass = run_paint_pass(r, frames, id); + print_mutation_stats(&pass); + } + "delete" => { + println!( + "\n=== Mutation: delete ({} leaves, one per frame) ===", + frames + ); + let pass = run_delete_pass(r, frames); + print_mutation_stats(&pass); + } + other => { + return Err(anyhow!( + "unknown --mutation kind '{}'. Expected: translate-root | translate-leaf | resize | paint | delete | all", + other + )); + } + } + Ok(()) + }; + + if kind == "all" { + // Run read-only mutations first (paint, translate variants), + // then resize, then delete last (delete reduces the scene). + for which in [ + "paint", + "translate-leaf", + "translate-root", + "resize", + "delete", + ] { + run_one(which, renderer)?; + } + } else { + run_one(&kind, renderer)?; + } + Ok(()) +} + /// Run a zoom pass with configurable step and range. /// Measures queue + flush per frame. Ends with a settle (stable) frame. fn run_zoom_pass_at( @@ -2401,6 +2885,14 @@ pub async fn run_bench(args: BenchArgs, load_scenes: impl AsyncSceneLoader) -> R comp_stats.memory_bytes as f64 / 1024.0, ); + // --- Unified mutation benchmark (--mutation=) --- + if let Some(ref spec) = args.mutation { + run_mutation_kind(&mut renderer, args.frames, spec)?; + drop(renderer); + println!("\nDone."); + return Ok(()); + } + // --- Node-translate mutation benchmark (--translate) --- if let Some(ref spec) = args.translate { use cg::node::schema::NodeId; diff --git a/crates/grida-dev/src/editor/mutation.rs b/crates/grida-dev/src/editor/mutation.rs index 612f94ba91..76be316a76 100644 --- a/crates/grida-dev/src/editor/mutation.rs +++ b/crates/grida-dev/src/editor/mutation.rs @@ -29,6 +29,12 @@ pub enum MutationCommand { width: Option, height: Option, }, + /// Replace the node's fills with a single solid color. Emits + /// `ChangeKind::Paint`. + SetFill { id: NodeId, color: CGColor }, + /// Remove the subtree rooted at `id`. Emits + /// `(parent_or_self, ChangeKind::Full)`. + Delete { id: NodeId }, } /// Apply a mutation command to a scene in-place. @@ -49,7 +55,7 @@ pub fn apply(scene: &mut Scene, cmd: &MutationCommand) -> Vec<(NodeId, ChangeKin let mut out = Vec::with_capacity(ids.len()); for id in ids { translate_node(&mut scene.graph, id, *dx, *dy); - out.push((*id, ChangeKind::Geometry)); + out.push((*id, ChangeKind::Layout)); } out } @@ -62,12 +68,28 @@ pub fn apply(scene: &mut Scene, cmd: &MutationCommand) -> Vec<(NodeId, ChangeKin return Vec::new(); } if resize_node(scene, id, *width, *height) { - // Resize is a size change → `Full` in v1. vec![(*id, ChangeKind::Full)] } else { Vec::new() } } + MutationCommand::SetFill { id, color } => { + if set_fill_solid(&mut scene.graph, id, *color) { + vec![(*id, ChangeKind::Paint)] + } else { + Vec::new() + } + } + MutationCommand::Delete { id } => { + // Report the parent (or `*id` for a root) with `Full` + // so the renderer full-rebuilds. The cg crate currently + // has no structural-remove fast path. + let parent = scene.graph.get_parent(id).unwrap_or(*id); + match scene.graph.remove_subtree(*id) { + Ok(_) => vec![(parent, ChangeKind::Full)], + Err(_) => Vec::new(), + } + } } } @@ -250,6 +272,61 @@ fn resize_node(scene: &mut Scene, id: &NodeId, width: Option, height: Optio changed } +/// Overwrite the node's fills with a single solid paint, if the node +/// supports fills. Returns `true` when the mutation landed. Emits +/// `ChangeKind::Paint` via `MutationCommand::SetFill`. +fn set_fill_solid(graph: &mut SceneGraph, id: &NodeId, color: CGColor) -> bool { + let Ok(node) = graph.get_node_mut(id) else { + return false; + }; + let paint = Paint::Solid(SolidPaint::new_color(color)); + match node { + Node::Rectangle(n) => { + n.fills = Paints::new([paint]); + true + } + Node::Ellipse(n) => { + n.fills = Paints::new([paint]); + true + } + Node::RegularPolygon(n) => { + n.fills = Paints::new([paint]); + true + } + Node::RegularStarPolygon(n) => { + n.fills = Paints::new([paint]); + true + } + Node::Line(_) => false, // lines use stroke, not fill + Node::TextSpan(n) => { + n.fills = Paints::new([paint]); + true + } + Node::AttributedText(_) => false, // per-span fills; out of scope here + Node::Path(n) => { + n.fills = Paints::new([paint]); + true + } + Node::Polygon(n) => { + n.fills = Paints::new([paint]); + true + } + Node::Vector(n) => { + n.fills = Paints::new([paint]); + true + } + Node::Container(n) => { + n.fills = Paints::new([paint]); + true + } + Node::Tray(n) => { + n.fills = Paints::new([paint]); + true + } + _ => false, + } +} + // ── Resize geometry helpers (pure math) ────────────────────────────────── /// Which axes a resize direction affects: `(width, height)`.