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:
parent
3687581b62
commit
4f2f3ff258
50
crates/op-editor-ui/src/widgets/canvas_overlay_transform.rs
Normal file
50
crates/op-editor-ui/src/widgets/canvas_overlay_transform.rs
Normal 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
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue