From 112d1f08bb7abe9fbdae16d4dfadc7748c38c358 Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Sun, 17 May 2026 17:47:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(canvas):=20pen=20bezier=20handle=20editing?= =?UTF-8?q?=20=E2=80=94=20render,=20handles,=20drag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/widgets/canvas_viewport.rs | 88 +++++++++++++---- .../src/widgets/canvas_viewport_paint.rs | 96 ++++++++++++++++++- crates/op-editor-ui/src/widgets/mod.rs | 4 +- crates/op-host-native/src/widget_host.rs | 36 +++++-- .../src/widget_host/geometry.rs | 46 +++++++-- .../op-host-native/src/widget_host/input.rs | 54 ++++++++--- .../src/widget_host/input_tests.rs | 9 ++ .../op-host-native/src/widget_host/press.rs | 20 ++-- 8 files changed, 298 insertions(+), 55 deletions(-) diff --git a/crates/op-editor-ui/src/widgets/canvas_viewport.rs b/crates/op-editor-ui/src/widgets/canvas_viewport.rs index 8004caeaa..d2ad4c1fe 100644 --- a/crates/op-editor-ui/src/widgets/canvas_viewport.rs +++ b/crates/op-editor-ui/src/widgets/canvas_viewport.rs @@ -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); } } } 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 1f35416ed..1843c3335 100644 --- a/crates/op-editor-ui/src/widgets/canvas_viewport_paint.rs +++ b/crates/op-editor-ui/src/widgets/canvas_viewport_paint.rs @@ -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 { + 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) -> 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)); + } +} diff --git a/crates/op-editor-ui/src/widgets/mod.rs b/crates/op-editor-ui/src/widgets/mod.rs index 6b2a13064..0f6c0c43b 100644 --- a/crates/op-editor-ui/src/widgets/mod.rs +++ b/crates/op-editor-ui/src/widgets/mod.rs @@ -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}; diff --git a/crates/op-host-native/src/widget_host.rs b/crates/op-host-native/src/widget_host.rs index 79396ca1f..e5c2cb713 100644 --- a/crates/op-host-native/src/widget_host.rs +++ b/crates/op-host-native/src/widget_host.rs @@ -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, diff --git a/crates/op-host-native/src/widget_host/geometry.rs b/crates/op-host-native/src/widget_host/geometry.rs index 0b1afa3e1..e9320c626 100644 --- a/crates/op-host-native/src/widget_host/geometry.rs +++ b/crates/op-host-native/src/widget_host/geometry.rs @@ -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 diff --git a/crates/op-host-native/src/widget_host/input.rs b/crates/op-host-native/src/widget_host/input.rs index 046263927..a21b00c5f 100644 --- a/crates/op-host-native/src/widget_host/input.rs +++ b/crates/op-host-native/src/widget_host/input.rs @@ -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; } diff --git a/crates/op-host-native/src/widget_host/input_tests.rs b/crates/op-host-native/src/widget_host/input_tests.rs index 65ed8f45b..9286f1850 100644 --- a/crates/op-host-native/src/widget_host/input_tests.rs +++ b/crates/op-host-native/src/widget_host/input_tests.rs @@ -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, }); diff --git a/crates/op-host-native/src/widget_host/press.rs b/crates/op-host-native/src/widget_host/press.rs index 6ca86733f..ff7270098 100644 --- a/crates/op-host-native/src/widget_host/press.rs +++ b/crates/op-host-native/src/widget_host/press.rs @@ -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, });