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.
This commit is contained in:
leinaldo 2026-07-09 09:56:54 +08:00 committed by GitHub
parent 3687581b62
commit 4f2f3ff258
5 changed files with 266 additions and 29 deletions

View file

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

View file

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

View file

@ -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<Rect>,
/// 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<OverlayTransform>,
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<OverlayTransform>,
) -> 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
}

View file

@ -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<Color>,
}
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<Vec<(f32, Point2D)>> {
let mut stroke_i = 0usize;
let mut rot_i = 0usize;
let mut stack: Vec<Vec<(f32, Point2D)>> = 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();

View file

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