feat(canvas): pen bezier handle editing — render, handles, drag

Completes pen bezier-handle editing on top of the Stage-1 data model.

- canvas_viewport_paint: `flatten_path` — Path nodes with anchor
  control handles render as tessellated cubic Beziers (16 steps /
  segment); handle-free paths keep the straight `points` polyline.
- canvas_viewport: the Pen-tool anchor overlay now draws each
  anchor's two control handles (line + dot), with a faint "ghost"
  dot offset from the anchor when a handle is unset — grab it to
  create the handle. `path_handle_positions` resolves real / ghost
  handle positions, shared with the host hit-test.
- op-host-native: `path_anchor_hit` now distinguishes anchor body
  vs handle_in / handle_out (`AnchorDragTarget`); `PathAnchorDragState`
  carries the target, the anchor's fixed position, and the Shift
  state. The move handler drags the anchor or a handle — a handle
  drag sets the anchor's point type on first motion (Shift =
  independent/broken, else mirrored/smooth) so `set_path_anchor_handle`
  mirrors the opposite handle. Release commits history only on
  actual motion.

op-editor-ui 148 / op-host-native 64 / op-host-desktop 21 /
op-pen-loader 21 tests green (+3 flatten-path units).

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Kayshen-X 2026-05-17 17:47:08 +08:00
parent 79b51573dd
commit 112d1f08bb
8 changed files with 298 additions and 55 deletions

View file

@ -24,7 +24,7 @@
use crate::layout_scene::LayoutScene;
use crate::layout_scene::NodeKind;
use crate::layout_scene::SceneNode;
use crate::layout_scene::{SceneAnchor, SceneNode};
use crate::theme::Theme;
use crate::widgets::editor_state_ext::theme_for;
use crate::widgets::{LayoutBox, LayoutCx, PaintCx, Widget, WidgetId};
@ -53,6 +53,26 @@ pub enum SelectionHandle {
/// 4 selection corners. Matches the TS `ROTATE_OUTER_RADIUS`.
const ROTATE_OUTER_RADIUS: f32 = 16.0;
/// Screen-px offset of a "ghost" handle dot from its anchor when the
/// handle is unset — far enough from the anchor body to grab.
pub const PATH_HANDLE_GHOST_PX: f32 = 26.0;
/// Doc-space positions of a path anchor's incoming + outgoing bezier
/// control handles. An unset handle is given a "ghost" position
/// offset from the anchor (scaled to `zoom`) so the user can grab it
/// to create the handle. Returns `(handle_in, handle_out)`. Shared by
/// the overlay painter and the host's handle hit-test.
pub fn path_handle_positions(anchor: &SceneAnchor, zoom: f32) -> (Point2D, Point2D) {
let ghost = PATH_HANDLE_GHOST_PX / zoom.max(0.0001);
let hin = anchor
.handle_in
.unwrap_or(Point2D::new(anchor.pos.x - ghost, anchor.pos.y));
let hout = anchor
.handle_out
.unwrap_or(Point2D::new(anchor.pos.x + ghost, anchor.pos.y));
(hin, hout)
}
/// The three arc-edit handles on a selected Ellipse — start angle,
/// sweep (end) angle, and the donut inner-radius.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -483,24 +503,60 @@ impl<'a> Widget for CanvasViewport<'a> {
}
// 4b. Per-anchor handles for the selected Path node when the
// Pen tool is active — surfaces the drag target that
// `path_anchor_drag` consumes.
// Pen tool is active — anchor dots plus the two bezier
// control handles (line + dot; a faint "ghost" dot when
// the handle is unset, draggable to create it).
if matches!(self.tool, op_editor_core::Tool::Pen) && self.selected_set.len() == 1 {
if let Some(page) = self.scene.active_page() {
if let Some(node) = page.find(&self.selected) {
if matches!(node.kind, NodeKind::Path) {
let r = 4.0; // screen-px radius
if let Some(node) = self
.scene
.active_page()
.and_then(|p| p.find(&self.selected))
{
if matches!(node.kind, NodeKind::Path) {
let zoom = viewport.zoom;
let to_screen = |p: Point2D| {
Point2D::new(
rect.origin.x + viewport.pan_x + p.x * zoom,
rect.origin.y + viewport.pan_y + p.y * zoom,
)
};
let dot = |cx: &mut PaintCx<'_>, c: Point2D, r: f32, fill, line| {
let b = Rect {
origin: Point2D::new(c.x - r, c.y - r),
size: Point2D::new(r * 2.0, r * 2.0),
};
cx.backend.fill_oval(b, fill);
cx.backend.stroke_oval(b, line, 1.5);
};
let ghost = crate::Color {
a: self.theme.primary.a * 0.4,
..self.theme.primary
};
for anchor in &node.path_anchors {
let center = to_screen(anchor.pos);
let (hin, hout) = path_handle_positions(anchor, zoom);
for (pos, is_set) in [
(hin, anchor.handle_in.is_some()),
(hout, anchor.handle_out.is_some()),
] {
let hs = to_screen(pos);
let tint = if is_set { self.theme.primary } else { ghost };
cx.backend.stroke_line(center, hs, tint, 1.0);
dot(cx, hs, 3.0, self.theme.background, tint);
}
// Anchor dot painted last so it sits on top.
dot(cx, center, 4.0, self.theme.background, self.theme.primary);
}
// Paths with no anchor data still show plain dots.
if node.path_anchors.is_empty() {
for p in &node.points {
let center = Point2D::new(
rect.origin.x + viewport.pan_x + p.x * viewport.zoom,
rect.origin.y + viewport.pan_y + p.y * viewport.zoom,
dot(
cx,
to_screen(*p),
4.0,
self.theme.background,
self.theme.primary,
);
let bounds = Rect {
origin: Point2D::new(center.x - r, center.y - r),
size: Point2D::new(r * 2.0, r * 2.0),
};
cx.backend.fill_oval(bounds, self.theme.background);
cx.backend.stroke_oval(bounds, self.theme.primary, 1.5);
}
}
}

View file

@ -110,6 +110,45 @@ fn paint_ellipse(cx: &mut PaintCx<'_>, node: &SceneNode, world_rect: Rect, zoom:
}
}
/// One point on the cubic Bezier `p0→p3` (control points `p1`,`p2`).
pub(crate) fn cubic_point(p0: Point2D, p1: Point2D, p2: Point2D, p3: Point2D, t: f32) -> Point2D {
let u = 1.0 - t;
let (w0, w1, w2, w3) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
Point2D::new(
w0 * p0.x + w1 * p1.x + w2 * p2.x + w3 * p3.x,
w0 * p0.y + w1 * p1.y + w2 * p2.y + w3 * p3.y,
)
}
/// Flatten a Path scene node into a doc-space polyline — cubic
/// segments whose endpoints carry handles are tessellated; a
/// handle-free path falls back to the straight `points` polyline.
pub(crate) fn flatten_path(node: &SceneNode) -> Vec<Point2D> {
let anchors = &node.path_anchors;
let has_handle = anchors
.iter()
.any(|a| a.handle_in.is_some() || a.handle_out.is_some());
if anchors.len() < 2 || !has_handle {
return node.points.clone();
}
let mut out = Vec::with_capacity(anchors.len() * 16);
out.push(anchors[0].pos);
for pair in anchors.windows(2) {
let (a, b) = (&pair[0], &pair[1]);
let (p0, p3) = (a.pos, b.pos);
let p1 = a.handle_out.unwrap_or(p0);
let p2 = b.handle_in.unwrap_or(p3);
if p1 == p0 && p2 == p3 {
out.push(p3); // straight segment
} else {
for i in 1..=16 {
out.push(cubic_point(p0, p1, p2, p3, i as f32 / 16.0));
}
}
}
out
}
/// Recursively paint one resolved [`SceneNode`] and its subtree.
///
/// `viewport_origin` is the canvas-rect origin shifted by the
@ -252,7 +291,11 @@ pub fn paint_node(
viewport_origin.y + p.y * zoom,
)
};
for pair in node.points.windows(2) {
// Bezier-aware: when the path carries anchors with control
// handles, flatten each cubic segment; otherwise fall back
// to the straight `points` polyline.
let polyline = flatten_path(node);
for pair in polyline.windows(2) {
cx.backend
.stroke_line(to_world(pair[0]), to_world(pair[1]), color, width);
}
@ -371,3 +414,54 @@ mod arc_tests {
assert!((last.y - 100.0).abs() < 0.01);
}
}
#[cfg(test)]
mod path_tests {
use super::flatten_path;
use crate::layout_scene::{NodeKind, SceneAnchor, SceneNode, ScenePointType};
use crate::{Point2D, Rect};
fn anchor(x: f32, y: f32, hout: Option<Point2D>) -> SceneAnchor {
SceneAnchor {
pos: Point2D::new(x, y),
handle_in: None,
handle_out: hout,
point_type: ScenePointType::Corner,
}
}
#[test]
fn handle_free_path_falls_back_to_points() {
let mut n = SceneNode::leaf("p", NodeKind::Path);
n.points = vec![Point2D::new(0.0, 0.0), Point2D::new(10.0, 0.0)];
n.path_anchors = vec![anchor(0.0, 0.0, None), anchor(10.0, 0.0, None)];
// No handles → straight polyline == points.
assert_eq!(flatten_path(&n), n.points);
}
#[test]
fn curved_segment_tessellates_into_many_points() {
let mut n = SceneNode::leaf("p", NodeKind::Path);
n.points = vec![Point2D::new(0.0, 0.0), Point2D::new(100.0, 0.0)];
n.path_anchors = vec![
anchor(0.0, 0.0, Some(Point2D::new(0.0, 50.0))),
anchor(100.0, 0.0, None),
];
let poly = flatten_path(&n);
// 1 start point + 16 tessellation steps for the cubic.
assert_eq!(poly.len(), 17);
assert_eq!(poly[0], Point2D::new(0.0, 0.0));
assert_eq!(poly[poly.len() - 1], Point2D::new(100.0, 0.0));
// Mid-curve bows toward the +Y handle.
assert!(poly[8].y > 1.0, "curve bows toward the handle");
}
#[test]
fn bounds_kept_so_helper_is_pure() {
// flatten_path must not mutate the node.
let mut n = SceneNode::leaf("p", NodeKind::Path);
n.bounds = Rect::xywh(1.0, 2.0, 3.0, 4.0);
let _ = flatten_path(&n);
assert_eq!(n.bounds, Rect::xywh(1.0, 2.0, 3.0, 4.0));
}
}

View file

@ -101,8 +101,8 @@ pub use property_panel::{PropertyPanel, PropertyPanelAction};
pub use toolbar::Toolbar;
pub use canvas_viewport::{
arc_handle_positions, rotation_corner_at_point, selection_handle_at_point, ArcHandle,
CanvasViewport, SelectionHandle,
arc_handle_positions, path_handle_positions, rotation_corner_at_point,
selection_handle_at_point, ArcHandle, CanvasViewport, SelectionHandle,
};
pub use icons::{draw_icon, Icon};

View file

@ -279,20 +279,38 @@ pub(in crate::widget_host) struct LayerDragState {
pub(in crate::widget_host) active: bool,
}
/// Path-anchor drag — tracks which anchor of which Path node is
/// being dragged by the pen tool. Move dispatches snap the anchor
/// to the cursor; release commits a history snapshot ONLY when the
/// anchor actually moved (codex CONCERN: a press-release without
/// motion pushed a no-op snapshot that polluted the undo stack).
/// What a path-anchor drag is editing — the anchor body itself, or
/// one of its two bezier control handles.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(in crate::widget_host) enum AnchorDragTarget {
/// The anchor point — drag moves the whole anchor.
Anchor,
/// A bezier control handle.
Handle(op_editor_core::pen::PathHandleSide),
}
/// Path-anchor drag — tracks which anchor (or which of its bezier
/// handles) of which Path node is being dragged by the pen tool.
/// Move dispatches snap the target to the cursor; release commits a
/// history snapshot ONLY when it actually moved (codex CONCERN: a
/// press-release without motion pushed a no-op snapshot that
/// polluted the undo stack).
#[derive(Debug, Clone)]
pub(in crate::widget_host) struct PathAnchorDragState {
pub(in crate::widget_host) node_id: op_editor_core::NodeId,
pub(in crate::widget_host) anchor_index: usize,
/// Anchor position at drag-start (doc coords) — compared against
/// the final position on release to decide whether to push the
/// snapshot.
/// Whether the anchor body or a handle is being dragged.
pub(in crate::widget_host) target: AnchorDragTarget,
/// The dragged anchor's absolute doc position, fixed at press —
/// handle drags compute their offset relative to it.
pub(in crate::widget_host) anchor_doc: op_editor_ui::Point2D,
/// Press cursor doc point — compared against the live cursor to
/// decide whether the drag actually moved.
pub(in crate::widget_host) start_doc: op_editor_ui::Point2D,
/// Set to true on the first cursor-move that mutates the anchor.
/// Shift held at press — a handle drag with Shift produces
/// independent (broken) handles instead of mirrored ones.
pub(in crate::widget_host) shift: bool,
/// Set to true on the first cursor-move that mutates the target.
pub(in crate::widget_host) moved: bool,
/// Snapshot captured at drag-start; pushed only if `moved`.
pub(in crate::widget_host) pre_drag_snapshot: op_editor_core::EditorSnapshot,

View file

@ -368,16 +368,20 @@ impl WidgetHostNative {
}
/// When the active selection is a single Path node + the Pen
/// tool is selected, hit-test whether `(screen_x, screen_y)`
/// lands on any anchor handle. Returns Some(index) on hit.
/// tool is selected, hit-test whether `(x, y)` lands on an anchor
/// or one of its bezier handles. Anchors are checked before
/// handles; returns the node id, anchor index, and which target.
pub(in crate::widget_host) fn path_anchor_hit(
&self,
x: f32,
y: f32,
viewport_w: f32,
viewport_h: f32,
) -> Option<(String, usize)> {
) -> Option<(String, usize, super::AnchorDragTarget)> {
use super::AnchorDragTarget;
use op_editor_core::pen::PathHandleSide;
use op_editor_ui::layout_scene::NodeKind;
use op_editor_ui::widgets::path_handle_positions;
if !matches!(self.editor_state.tool, op_editor_core::Tool::Pen) {
return None;
}
@ -392,13 +396,35 @@ impl WidgetHostNative {
let (cx0, cy0, _cw, _ch) = self.canvas_region(viewport_w, viewport_h);
let zoom = self.editor_state.viewport.zoom.max(0.0001);
let canvas_local = Point2D::new(x - cx0, y - cy0);
let doc_point = self.editor_state.viewport.to_document(canvas_local);
let r2 = 16.0 / (zoom * zoom);
for (i, p) in node.points.iter().enumerate() {
let dx = doc_point.x - p.x;
let dy = doc_point.y - p.y;
if dx * dx + dy * dy <= r2 {
return Some((sel, i));
let doc = self.editor_state.viewport.to_document(canvas_local);
// ~7 screen-px grab radius, expressed in doc space.
let r2 = 49.0 / (zoom * zoom);
let hit = |p: Point2D| (doc.x - p.x).powi(2) + (doc.y - p.y).powi(2) <= r2;
// Anchors take priority over handles within their tight body.
for (i, a) in node.path_anchors.iter().enumerate() {
if hit(a.pos) {
return Some((sel.clone(), i, AnchorDragTarget::Anchor));
}
}
for (i, a) in node.path_anchors.iter().enumerate() {
let (hin, hout) = path_handle_positions(a, zoom);
if hit(hout) {
return Some((
sel.clone(),
i,
AnchorDragTarget::Handle(PathHandleSide::Out),
));
}
if hit(hin) {
return Some((sel.clone(), i, AnchorDragTarget::Handle(PathHandleSide::In)));
}
}
// Paths without resolved anchor data fall back to `points`.
if node.path_anchors.is_empty() {
for (i, p) in node.points.iter().enumerate() {
if hit(*p) {
return Some((sel.clone(), i, AnchorDragTarget::Anchor));
}
}
}
None

View file

@ -321,23 +321,55 @@ impl WidgetHostNative {
}
return false;
}
// Path-anchor drag — always write the current cursor position
// (codex BLOCK: drag-back-to-start was being silently dropped).
// Path-anchor / handle drag — write the current cursor
// position (codex BLOCK: drag-back-to-start was being
// silently dropped, so always write).
if self.path_anchor_drag.is_some() {
use super::AnchorDragTarget;
let (cx0, cy0) = self.canvas_origin();
let canvas_local = Point2D::new(x - cx0, y - cy0);
let doc_point = self.editor_state.viewport.to_document(canvas_local);
let (id, idx, start) = {
let doc = self.editor_state.viewport.to_document(canvas_local);
let (id, idx, target, anchor_doc, start, shift, already_moved) = {
let d = self.path_anchor_drag.as_ref().unwrap();
(d.node_id.clone(), d.anchor_index, d.start_doc)
(
d.node_id.clone(),
d.anchor_index,
d.target,
d.anchor_doc,
d.start_doc,
d.shift,
d.moved,
)
};
self.editor_state.set_path_anchor_position(
id,
idx,
(doc_point.x as f64, doc_point.y as f64),
);
let is_move = (doc.x - start.x).abs() > 0.001 || (doc.y - start.y).abs() > 0.001;
match target {
AnchorDragTarget::Anchor => {
self.editor_state.set_path_anchor_position(
id,
idx,
(doc.x as f64, doc.y as f64),
);
}
AnchorDragTarget::Handle(side) => {
// First real move sets the anchor's point type —
// Shift = independent (broken) handles, else
// mirrored (smooth).
if !already_moved && is_move {
let pt = if shift {
jian_ops_schema::node::PenPathPointType::Independent
} else {
jian_ops_schema::node::PenPathPointType::Mirrored
};
self.editor_state
.set_path_anchor_point_type(id.clone(), idx, pt);
}
let delta = ((doc.x - anchor_doc.x) as f64, (doc.y - anchor_doc.y) as f64);
self.editor_state
.set_path_anchor_handle(id, idx, side, Some(delta));
}
}
self.mark_dirty();
if (doc_point.x - start.x).abs() > 0.001 || (doc_point.y - start.y).abs() > 0.001 {
if is_move {
if let Some(d) = self.path_anchor_drag.as_mut() {
d.moved = true;
}

View file

@ -348,7 +348,10 @@ fn anchor_press_release_without_motion_does_not_push_history() {
host.path_anchor_drag = Some(crate::widget_host::PathAnchorDragState {
node_id: NodeId::new("n60"),
anchor_index: 1,
target: crate::widget_host::AnchorDragTarget::Anchor,
anchor_doc: Point2D::new(50.0, 25.0),
start_doc: Point2D::new(50.0, 25.0),
shift: false,
moved: false,
pre_drag_snapshot: snap,
});
@ -383,7 +386,10 @@ fn anchor_drag_back_to_start_lands_at_start() {
host.path_anchor_drag = Some(crate::widget_host::PathAnchorDragState {
node_id: NodeId::new("n60"),
anchor_index: 1,
target: crate::widget_host::AnchorDragTarget::Anchor,
anchor_doc: Point2D::new(50.0, 25.0),
start_doc: Point2D::new(50.0, 25.0),
shift: false,
moved: false,
pre_drag_snapshot: snap,
});
@ -420,7 +426,10 @@ fn anchor_drag_with_motion_pushes_one_history_entry() {
host.path_anchor_drag = Some(crate::widget_host::PathAnchorDragState {
node_id: NodeId::new("n60"),
anchor_index: 1,
target: crate::widget_host::AnchorDragTarget::Anchor,
anchor_doc: Point2D::new(50.0, 25.0),
start_doc: Point2D::new(50.0, 25.0),
shift: false,
moved: true,
pre_drag_snapshot: snap,
});

View file

@ -707,23 +707,31 @@ impl WidgetHostNative {
// Pen tool: anchor edit / author.
if matches!(self.editor_state.tool, Tool::Pen) {
if self.editor_state.ui.pen_in_progress.is_none() {
if let Some((node_id, anchor_index)) =
if let Some((node_id, anchor_index, target)) =
self.path_anchor_hit(x, y, viewport_width, viewport_height)
{
let ec_id = op_editor_core::NodeId::new(&node_id);
// Capture starting position for the
// history-pollution guard.
let start_doc = self
// The anchor's fixed absolute position — handle
// drags offset their delta against it.
let anchor_doc = self
.layout_scene
.active_page()
.and_then(|p| p.find(&node_id))
.and_then(|n| n.points.get(anchor_index).copied())
.and_then(|n| {
n.path_anchors
.get(anchor_index)
.map(|a| a.pos)
.or_else(|| n.points.get(anchor_index).copied())
})
.unwrap_or(doc_point);
let pre = self.editor_state.snapshot_for_history();
self.path_anchor_drag = Some(super::PathAnchorDragState {
node_id: ec_id,
anchor_index,
start_doc,
target,
anchor_doc,
start_doc: doc_point,
shift: self.shift_held,
moved: false,
pre_drag_snapshot: pre,
});