From 4f2f3ff2582cb91e52e218f9c8e5e3da4a6e365f Mon Sep 17 00:00:00 2001 From: leinaldo <60176594+leinaldo@users.noreply.github.com> Date: Thu, 9 Jul 2026 09:56:54 +0800 Subject: [PATCH] fix(canvas): rotate hover outline with node and ancestor transforms (#174) The hover dashed outline painted at page level from the unrotated doc-space bounds, so a rotated frame (and any element inside one) showed an axis-aligned outline detached from its rendered geometry. Accumulate the root-to-node flip/rotation chain during the scene paint walk and replay it around the dashed rect so the outline lands exactly where the node renders. Hit-testing already handled ancestor rotation, so trigger area and visual now agree. --- .../src/widgets/canvas_overlay_transform.rs | 50 ++++++ .../src/widgets/canvas_viewport.rs | 44 +++-- .../src/widgets/canvas_viewport_paint.rs | 49 +++++- .../src/widgets/canvas_viewport_tests.rs | 151 +++++++++++++++++- crates/op-editor-ui/src/widgets/mod.rs | 1 + 5 files changed, 266 insertions(+), 29 deletions(-) create mode 100644 crates/op-editor-ui/src/widgets/canvas_overlay_transform.rs diff --git a/crates/op-editor-ui/src/widgets/canvas_overlay_transform.rs b/crates/op-editor-ui/src/widgets/canvas_overlay_transform.rs new file mode 100644 index 000000000..69c80fa99 --- /dev/null +++ b/crates/op-editor-ui/src/widgets/canvas_overlay_transform.rs @@ -0,0 +1,50 @@ +//! Root→node overlay transform chains for editor chrome. +//! +//! The canvas paints a node inside its ancestors' save/scale/rotate +//! stack, but editor overlays (e.g. the hover outline) paint at page +//! level — outside every node transform. This module carries the +//! chain of per-node flip/rotation transforms from the page root down +//! to a node so overlays can replay it and land exactly on the +//! rendered geometry. +//! +//! Pivots are SCREEN-space (viewport transform already applied), so a +//! chain is only valid for the pan/zoom it was built with. + +use crate::widgets::PaintCx; +use crate::Point2D; + +/// One node's paint transform — flip scale first, then rotation, both +/// about `pivot` (screen space), matching the order `paint_node_inner` +/// applies to the node's own paint. +#[derive(Clone, Copy, PartialEq, Debug)] +pub(crate) struct OverlayTransform { + pub(crate) rotation: f32, + pub(crate) flip_x: bool, + pub(crate) flip_y: bool, + pub(crate) pivot: Point2D, +} + +/// Push the chain onto the paint backend (one `save` + the scale / +/// rotate sequence, root first). Returns whether a `save` was pushed — +/// the caller must `restore` after painting when true. +pub(crate) fn replay_on_backend(cx: &mut PaintCx<'_>, transforms: &[OverlayTransform]) -> bool { + if transforms.is_empty() { + return false; + } + cx.backend.save(); + for t in transforms { + if t.flip_x || t.flip_y { + cx.backend.scale( + Point2D::new( + if t.flip_x { -1.0 } else { 1.0 }, + if t.flip_y { -1.0 } else { 1.0 }, + ), + t.pivot, + ); + } + if t.rotation.abs() > f32::EPSILON { + cx.backend.rotate(t.rotation, t.pivot); + } + } + true +} diff --git a/crates/op-editor-ui/src/widgets/canvas_viewport.rs b/crates/op-editor-ui/src/widgets/canvas_viewport.rs index 56828e661..db5137852 100644 --- a/crates/op-editor-ui/src/widgets/canvas_viewport.rs +++ b/crates/op-editor-ui/src/widgets/canvas_viewport.rs @@ -200,23 +200,6 @@ pub fn rotation_corner_at_point( None } -/// Apply the inverse of a rotation about `pivot` to `point`. Used -/// by hit-tests so a rotated selection's handles + rotation ring -/// + body all match the rendered (rotated) geometry. -fn inverse_rotate(point: Point2D, pivot: Point2D, radians: f32) -> Point2D { - if radians.abs() < f32::EPSILON { - return point; - } - let dx = point.x - pivot.x; - let dy = point.y - pivot.y; - let cos_t = (-radians).cos(); - let sin_t = (-radians).sin(); - Point2D::new( - pivot.x + dx * cos_t - dy * sin_t, - pivot.y + dx * sin_t + dy * cos_t, - ) -} - /// Hit-test the 8 selection handles around the currently-selected /// node. Returns the handle at `point` (a small slop around each /// handle center counts) or `None` if no selection / no handle. @@ -276,6 +259,23 @@ pub fn selection_handle_at_point( None } +/// Apply the inverse of a rotation about `pivot` to `point`. Used +/// by hit-tests so a rotated selection's handles + rotation ring +/// + body all match the rendered (rotated) geometry. +fn inverse_rotate(point: Point2D, pivot: Point2D, radians: f32) -> Point2D { + if radians.abs() < f32::EPSILON { + return point; + } + let dx = point.x - pivot.x; + let dy = point.y - pivot.y; + let cos_t = (-radians).cos(); + let sin_t = (-radians).sin(); + Point2D::new( + pivot.x + dx * cos_t - dy * sin_t, + pivot.y + dx * sin_t + dy * cos_t, + ) +} + /// Caret-blink descriptor for the text node currently being edited. /// `pub` so the sibling `canvas_viewport_paint` module can name it in /// the public `paint_node` signature. @@ -691,7 +691,17 @@ impl<'a> Widget for CanvasViewport<'a> { b: 0.965, a: 1.0, }; + // Replay the hovered node's root→node flip/rotation + // chain so the dashed outline lands on the rendered + // geometry, not the unrotated doc-space bounds. + let hover_transformed = super::canvas_overlay_transform::replay_on_backend( + cx, + &paint_hits.hover_transforms, + ); paint_dashed_rect(cx, screen, HOVER, 1.5); + if hover_transformed { + cx.backend.restore(); + } } super::canvas_frame_labels::paint_frame_labels( cx, diff --git a/crates/op-editor-ui/src/widgets/canvas_viewport_paint.rs b/crates/op-editor-ui/src/widgets/canvas_viewport_paint.rs index 78567a805..7f3059972 100644 --- a/crates/op-editor-ui/src/widgets/canvas_viewport_paint.rs +++ b/crates/op-editor-ui/src/widgets/canvas_viewport_paint.rs @@ -269,17 +269,32 @@ struct PaintNodeOptions<'a> { hidden: Option<&'a str>, } +use super::canvas_overlay_transform::OverlayTransform; + #[derive(Default)] pub struct PaintNodeHits<'a> { pub(crate) hover_rect: Option, + /// Root→node transform chain active where the hovered node paints; + /// empty when `hover_rect` is `None` or the chain is identity. + pub(crate) hover_transforms: Vec, pub(crate) selected_node: Option<&'a SceneNode>, pub(crate) pen_node: Option<&'a SceneNode>, } impl<'a> PaintNodeHits<'a> { - fn for_node(node: &'a SceneNode, options: &PaintNodeOptions<'_>) -> Self { + fn for_node( + node: &'a SceneNode, + options: &PaintNodeOptions<'_>, + transforms: &[OverlayTransform], + ) -> Self { + let hover_rect = hovered_outline_rect(node, options); Self { - hover_rect: hovered_outline_rect(node, options), + hover_transforms: if hover_rect.is_some() { + transforms.to_vec() + } else { + Vec::new() + }, + hover_rect, selected_node: (options.selected == Some(node.id.as_str())).then_some(node), pen_node: (options.pen == Some(node.id.as_str())).then_some(node), } @@ -288,6 +303,7 @@ impl<'a> PaintNodeHits<'a> { pub(crate) fn merge_missing(&mut self, child: Self) { if self.hover_rect.is_none() { self.hover_rect = child.hover_rect; + self.hover_transforms = child.hover_transforms; } if self.selected_node.is_none() { self.selected_node = child.selected_node; @@ -386,7 +402,7 @@ pub(crate) fn paint_node_with_options_hiding<'a>( pen, hidden, }; - paint_node_inner(cx, node, &options) + paint_node_inner(cx, node, &options, &mut Vec::new()) } /// Paint a resolved scene page's node tree with the editor viewport @@ -432,6 +448,7 @@ fn paint_node_inner<'a>( cx: &mut PaintCx<'_>, node: &'a SceneNode, options: &PaintNodeOptions<'_>, + transforms: &mut Vec, ) -> PaintNodeHits<'a> { if options.hidden == Some(node.id.as_str()) { return PaintNodeHits::default(); @@ -471,8 +488,6 @@ fn paint_node_inner<'a>( return PaintNodeHits::default(); } } - let mut hits = PaintNodeHits::for_node(node, options); - // Wrap the paint in save/transform/restore when the node carries // a mirror, a non-zero rotation, or an in-flight reveal pop. All // pivot around the node's own bounds centre; containers use their @@ -480,6 +495,9 @@ fn paint_node_inner<'a>( let flipped = node.flip_x || node.flip_y; let rotated = node.rotation.abs() > f32::EPSILON; let transformed = flipped || rotated || pop_scale.is_some(); + // Flip/rotation (not the transient reveal pop) also joins the + // overlay transform chain so the hover outline replays it. + let overlay_transformed = flipped || rotated; if transformed { let pivot_doc = node.aggregate_bounds(); let pivot = Point2D::new( @@ -502,7 +520,16 @@ fn paint_node_inner<'a>( if rotated { cx.backend.rotate(node.rotation, pivot); } + if overlay_transformed { + transforms.push(OverlayTransform { + rotation: node.rotation, + flip_x: node.flip_x, + flip_y: node.flip_y, + pivot, + }); + } } + let mut hits = PaintNodeHits::for_node(node, options, transforms); // Gaussian layer blur (Figma "Layer blur"): capture the node's // whole rendered output — shadows, fill, stroke, children — into @@ -551,7 +578,7 @@ fn paint_node_inner<'a>( paint_widget_visual(cx, node, world_rect, zoom); let clipped = push_clip_content(cx, node, world_rect, zoom); for child in node.children.iter().rev() { - let child_hover = paint_node_inner(cx, child, options); + let child_hover = paint_node_inner(cx, child, options, transforms); hits.merge_missing(child_hover); } if clipped { @@ -571,7 +598,7 @@ fn paint_node_inner<'a>( // every recursing container branch, not just Frame. let clipped = push_clip_content(cx, node, world_rect, zoom); for child in node.children.iter().rev() { - let child_hover = paint_node_inner(cx, child, options); + let child_hover = paint_node_inner(cx, child, options, transforms); hits.merge_missing(child_hover); } if clipped { @@ -605,7 +632,7 @@ fn paint_node_inner<'a>( // all rendered as blank cards). let clipped = push_clip_content(cx, node, world_rect, zoom); for child in node.children.iter().rev() { - let child_hover = paint_node_inner(cx, child, options); + let child_hover = paint_node_inner(cx, child, options, transforms); hits.merge_missing(child_hover); } if clipped { @@ -669,6 +696,9 @@ fn paint_node_inner<'a>( if transformed { cx.backend.restore(); } + if overlay_transformed { + transforms.pop(); + } return hits; } // Bezier-aware: when the path carries anchors with control @@ -731,6 +761,9 @@ fn paint_node_inner<'a>( if transformed { cx.backend.restore(); } + if overlay_transformed { + transforms.pop(); + } hits } diff --git a/crates/op-editor-ui/src/widgets/canvas_viewport_tests.rs b/crates/op-editor-ui/src/widgets/canvas_viewport_tests.rs index fdc6e2d36..100dc5c6f 100644 --- a/crates/op-editor-ui/src/widgets/canvas_viewport_tests.rs +++ b/crates/op-editor-ui/src/widgets/canvas_viewport_tests.rs @@ -15,6 +15,7 @@ enum Op { Restore, Clip, Scale, + Rotate, Fill, Stroke, Text, @@ -33,6 +34,10 @@ struct RecordingBackend { dots: usize, mesh_fills: usize, shader_fills: usize, + /// One `(radians, pivot)` per [`Op::Rotate`], in op order. + rotations: Vec<(f32, Point2D)>, + /// One color per [`Op::Stroke`], in op order. + stroke_colors: Vec, } impl crate::RenderBackend for RecordingBackend { @@ -43,8 +48,9 @@ impl crate::RenderBackend for RecordingBackend { self.rects += 1; self.ops.push(Op::Fill); } - fn stroke_rect(&mut self, _: Rect, _: Color, _: f32) { + fn stroke_rect(&mut self, _: Rect, color: Color, _: f32) { self.strokes += 1; + self.stroke_colors.push(color); self.ops.push(Op::Stroke); } fn draw_text(&mut self, layout: &TextLayout, point: Point2D) { @@ -70,8 +76,13 @@ impl crate::RenderBackend for RecordingBackend { fn scale(&mut self, _: Point2D, _: Point2D) { self.ops.push(Op::Scale); } - fn stroke_line(&mut self, _: Point2D, _: Point2D, _: Color, _: f32) { + fn rotate(&mut self, radians: f32, pivot: Point2D) { + self.rotations.push((radians, pivot)); + self.ops.push(Op::Rotate); + } + fn stroke_line(&mut self, _: Point2D, _: Point2D, color: Color, _: f32) { self.strokes += 1; + self.stroke_colors.push(color); self.ops.push(Op::Stroke); } fn fill_round_rect(&mut self, rect: Rect, _: f32, _: Color) { @@ -83,12 +94,14 @@ impl crate::RenderBackend for RecordingBackend { self.dots += centers.len(); self.ops.push(Op::Fill); } - fn stroke_round_rect(&mut self, _: Rect, _: f32, _: Color, _: f32) { + fn stroke_round_rect(&mut self, _: Rect, _: f32, color: Color, _: f32) { self.strokes += 1; + self.stroke_colors.push(color); self.ops.push(Op::Stroke); } - fn stroke_svg_path(&mut self, _: &str, _: Point2D, _: f32, _: Color, _: f32) { + fn stroke_svg_path(&mut self, _: &str, _: Point2D, _: f32, color: Color, _: f32) { self.strokes += 1; + self.stroke_colors.push(color); self.ops.push(Op::Stroke); } fn fill_round_rect_mesh_gradient( @@ -1055,6 +1068,136 @@ fn hover_outline_does_not_deep_search_after_scene_paint() { ); } +/// Mirror of the hover-outline stroke color in `canvas_viewport.rs`. +const HOVER_OUTLINE_COLOR: Color = Color { + r: 0.231, + g: 0.51, + b: 0.965, + a: 1.0, +}; + +/// Replay the recorded op stream up to the first stroke painted in +/// `color`, tracking the save/restore transform stack, and return the +/// `(radians, pivot)` rotations active at that stroke. `None` when no +/// stroke of that color was painted. +fn active_rotations_at_first_stroke( + backend: &RecordingBackend, + color: Color, +) -> Option> { + let mut stroke_i = 0usize; + let mut rot_i = 0usize; + let mut stack: Vec> = vec![Vec::new()]; + for op in &backend.ops { + match op { + Op::Save => { + let top = stack.last().cloned().unwrap_or_default(); + stack.push(top); + } + Op::Restore => { + stack.pop(); + } + Op::Rotate => { + stack + .last_mut() + .expect("unbalanced save/restore") + .push(backend.rotations[rot_i]); + rot_i += 1; + } + Op::Stroke => { + if backend.stroke_colors[stroke_i] == color { + return Some(stack.last().cloned().unwrap_or_default()); + } + stroke_i += 1; + } + _ => {} + } + } + None +} + +#[test] +fn hover_outline_rotates_with_rotated_frame() { + let _guard = crate::agent_indicator_test_support::lock(); + op_editor_core::agent_indicators::clear(); + let mut scene = sample_scene(); + let rotation = 0.5_f32; + scene.pages[0].children[0].rotation = rotation; + let state = EditorState::new(); + let mut viewport = CanvasViewport::from_editor(&state, &scene); + viewport.hovered = Some("n1".into()); + let mut backend = RecordingBackend::default(); + let rect = Rect::xywh(0.0, 0.0, 800.0, 600.0); + { + let mut cx = PaintCx { + backend: &mut backend, + }; + viewport.paint(&mut cx, rect); + } + + let rotations = active_rotations_at_first_stroke(&backend, HOVER_OUTLINE_COLOR) + .expect("hover outline should paint dashed strokes"); + assert_eq!( + rotations.len(), + 1, + "hover outline should paint under the hovered frame's rotation" + ); + // Frame n1 spans (40, 40, 320, 200) → doc-space center (200, 140). + let vp = &viewport.viewport; + let pivot = Point2D::new( + rect.origin.x + vp.pan_x + 200.0 * vp.zoom, + rect.origin.y + vp.pan_y + 140.0 * vp.zoom, + ); + assert!((rotations[0].0 - rotation).abs() < 1e-4); + assert!( + (rotations[0].1.x - pivot.x).abs() < 0.5 && (rotations[0].1.y - pivot.y).abs() < 0.5, + "hover outline must rotate about the frame's own center; got {:?}, want {:?}", + rotations[0].1, + pivot + ); +} + +#[test] +fn hover_outline_on_child_applies_ancestor_rotation() { + let _guard = crate::agent_indicator_test_support::lock(); + op_editor_core::agent_indicators::clear(); + let mut scene = sample_scene(); + let rotation = 0.5_f32; + scene.pages[0].children[0].rotation = rotation; + let state = EditorState::new(); + let mut viewport = CanvasViewport::from_editor(&state, &scene); + // n2 is an unrotated child of the rotated frame n1. + viewport.hovered = Some("n2".into()); + let mut backend = RecordingBackend::default(); + let rect = Rect::xywh(0.0, 0.0, 800.0, 600.0); + { + let mut cx = PaintCx { + backend: &mut backend, + }; + viewport.paint(&mut cx, rect); + } + + let rotations = active_rotations_at_first_stroke(&backend, HOVER_OUTLINE_COLOR) + .expect("hover outline should paint dashed strokes"); + assert_eq!( + rotations.len(), + 1, + "child hover outline should inherit the rotated ancestor's transform" + ); + // The pivot is the rotated PARENT's center, not the child's. + let vp = &viewport.viewport; + let pivot = Point2D::new( + rect.origin.x + vp.pan_x + 200.0 * vp.zoom, + rect.origin.y + vp.pan_y + 140.0 * vp.zoom, + ); + assert!((rotations[0].0 - rotation).abs() < 1e-4); + assert!( + (rotations[0].1.x - pivot.x).abs() < 0.5 && (rotations[0].1.y - pivot.y).abs() < 0.5, + "child hover outline must rotate about the ancestor's pivot; got {:?}, want {:?}", + rotations[0].1, + pivot + ); +} + #[test] fn flipped_node_applies_scale_transform() { let state = sample_state(); diff --git a/crates/op-editor-ui/src/widgets/mod.rs b/crates/op-editor-ui/src/widgets/mod.rs index 3899c8419..6c6332ef8 100644 --- a/crates/op-editor-ui/src/widgets/mod.rs +++ b/crates/op-editor-ui/src/widgets/mod.rs @@ -110,6 +110,7 @@ mod canvas_agent_cursor; mod canvas_agent_cursor_tests; mod canvas_frame_labels; pub mod canvas_layout_transition; +pub mod canvas_overlay_transform; mod canvas_path_overlay; mod canvas_selection_overlay; pub mod canvas_text_edit;