diff --git a/crates/op-editor-core/src/pen.rs b/crates/op-editor-core/src/pen.rs index 08635ad48..75e1c6b38 100644 --- a/crates/op-editor-core/src/pen.rs +++ b/crates/op-editor-core/src/pen.rs @@ -9,7 +9,16 @@ use crate::pen_node_ext::{make_path, PenNodeExt}; use crate::state::EditorState; use crate::walkers::{self, find_node, find_node_mut}; use jian_ops_schema::node::PenNode; -use jian_ops_schema::node::PenPathAnchor; +use jian_ops_schema::node::{PenPathAnchor, PenPathHandle, PenPathPointType}; + +/// Which bezier control handle of a path anchor is being edited. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PathHandleSide { + /// The incoming handle (controls the curve arriving at the anchor). + In, + /// The outgoing handle (controls the curve leaving the anchor). + Out, +} /// Bounding box of a set of anchors: `(x, y, w, h)`. fn anchor_bbox(anchors: &[PenPathAnchor]) -> (f64, f64, f64, f64) { @@ -121,6 +130,93 @@ impl EditorState { true } + /// Set (or clear, with `delta = None`) a bezier control handle on + /// a path anchor. `delta` is the handle offset relative to the + /// anchor. When the anchor's `point_type` is `Mirrored`, the + /// opposite handle is set to the negated offset so the two stay + /// collinear + equal-length. History is the caller's + /// responsibility. + pub fn set_path_anchor_handle( + &mut self, + node_id: NodeId, + index: usize, + side: PathHandleSide, + delta: Option<(f64, f64)>, + ) -> bool { + if !self.is_editable(&node_id) { + return false; + } + let Some(node) = find_node_mut(self.active_children_mut(), &node_id) else { + return false; + }; + let PenNode::Path(path) = node else { + return false; + }; + let Some(anchors) = path.anchors.as_mut() else { + return false; + }; + let Some(anchor) = anchors.get_mut(index) else { + return false; + }; + let handle = delta.map(|(x, y)| PenPathHandle { x, y }); + match side { + PathHandleSide::In => anchor.handle_in = handle, + PathHandleSide::Out => anchor.handle_out = handle, + } + // Mirrored anchors keep both handles collinear + equal length. + if anchor.point_type == Some(PenPathPointType::Mirrored) { + if let Some((x, y)) = delta { + let mirror = Some(PenPathHandle { x: -x, y: -y }); + match side { + PathHandleSide::In => anchor.handle_out = mirror, + PathHandleSide::Out => anchor.handle_in = mirror, + } + } + } + true + } + + /// Set a path anchor's point type. Switching to `Mirrored` snaps + /// the two handles collinear (the existing handle defines the + /// axis; the opposite becomes its negation). History is the + /// caller's responsibility. + pub fn set_path_anchor_point_type( + &mut self, + node_id: NodeId, + index: usize, + point_type: PenPathPointType, + ) -> bool { + if !self.is_editable(&node_id) { + return false; + } + let Some(node) = find_node_mut(self.active_children_mut(), &node_id) else { + return false; + }; + let PenNode::Path(path) = node else { + return false; + }; + let Some(anchors) = path.anchors.as_mut() else { + return false; + }; + let Some(anchor) = anchors.get_mut(index) else { + return false; + }; + let is_mirrored = point_type == PenPathPointType::Mirrored; + anchor.point_type = Some(point_type); + if is_mirrored { + match (anchor.handle_out.clone(), anchor.handle_in.clone()) { + (Some(h), _) => { + anchor.handle_in = Some(PenPathHandle { x: -h.x, y: -h.y }); + } + (None, Some(h)) => { + anchor.handle_out = Some(PenPathHandle { x: -h.x, y: -h.y }); + } + (None, None) => {} + } + } + true + } + /// Commit the in-progress Pen path. Pushes the pre-pen snapshot /// onto the undo stack only when the path has ≥ 2 anchors — /// otherwise the lone-anchor node is stripped without polluting diff --git a/crates/op-editor-core/src/tests_pages.rs b/crates/op-editor-core/src/tests_pages.rs index e29e1202d..798452dc1 100644 --- a/crates/op-editor-core/src/tests_pages.rs +++ b/crates/op-editor-core/src/tests_pages.rs @@ -194,3 +194,59 @@ fn set_path_anchor_position_rejects_out_of_range() { s.finish_pen_path(); assert!(!s.set_path_anchor_position(id, 99, (0.0, 0.0))); } + +#[test] +fn set_path_anchor_handle_writes_and_clears() { + use crate::pen::PathHandleSide; + use jian_ops_schema::node::PenNode; + let mut s = state_with(vec![]); + let mut next_id = 1u64; + let id = s.start_pen_path(&mut next_id, (0.0, 0.0)).expect("start"); + s.add_pen_point((100.0, 0.0)); + s.finish_pen_path(); + // Set the outgoing handle on anchor 0. + assert!(s.set_path_anchor_handle(id.clone(), 0, PathHandleSide::Out, Some((20.0, 10.0)))); + if let Some(PenNode::Path(p)) = find_node(s.active_children(), &id) { + let h = p.anchors.as_ref().unwrap()[0].handle_out.as_ref().unwrap(); + assert_eq!((h.x, h.y), (20.0, 10.0)); + } else { + panic!("expected path"); + } + // Clearing it sets the handle back to None. + assert!(s.set_path_anchor_handle(id.clone(), 0, PathHandleSide::Out, None)); + if let Some(PenNode::Path(p)) = find_node(s.active_children(), &id) { + assert!(p.anchors.as_ref().unwrap()[0].handle_out.is_none()); + } +} + +#[test] +fn mirrored_point_type_mirrors_the_opposite_handle() { + use crate::pen::PathHandleSide; + use jian_ops_schema::node::{PenNode, PenPathPointType}; + let mut s = state_with(vec![]); + let mut next_id = 1u64; + let id = s.start_pen_path(&mut next_id, (0.0, 0.0)).expect("start"); + s.add_pen_point((100.0, 0.0)); + s.finish_pen_path(); + s.set_path_anchor_point_type(id.clone(), 0, PenPathPointType::Mirrored); + // Dragging the outgoing handle mirrors the incoming one. + s.set_path_anchor_handle(id.clone(), 0, PathHandleSide::Out, Some((30.0, 12.0))); + if let Some(PenNode::Path(p)) = find_node(s.active_children(), &id) { + let a = &p.anchors.as_ref().unwrap()[0]; + let hin = a.handle_in.as_ref().unwrap(); + assert_eq!((hin.x, hin.y), (-30.0, -12.0)); + } else { + panic!("expected path"); + } +} + +#[test] +fn set_path_anchor_handle_rejects_bad_index() { + use crate::pen::PathHandleSide; + let mut s = state_with(vec![]); + let mut next_id = 1u64; + let id = s.start_pen_path(&mut next_id, (0.0, 0.0)).expect("start"); + s.add_pen_point((10.0, 10.0)); + s.finish_pen_path(); + assert!(!s.set_path_anchor_handle(id, 99, PathHandleSide::In, Some((1.0, 1.0)))); +} diff --git a/crates/op-editor-ui/src/layout_scene.rs b/crates/op-editor-ui/src/layout_scene.rs index f52209db6..e882c131c 100644 --- a/crates/op-editor-ui/src/layout_scene.rs +++ b/crates/op-editor-ui/src/layout_scene.rs @@ -148,6 +148,28 @@ pub struct ScenePage { /// `text_wrap`, `points`, `effects`, `children`, `hidden`) so a /// painter over `LayoutScene` can reproduce the current canvas /// pixel-for-pixel. +/// How a path anchor's two control handles relate. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ScenePointType { + /// Handles independent; no smoothing across the anchor. + Corner, + /// Handles collinear + equal length. + Mirrored, + /// Handles move freely + independently. + Independent, +} + +/// A path bezier anchor resolved into absolute doc coords — the +/// anchor point plus its (optional) incoming / outgoing control +/// handles. Handle positions are absolute, not anchor-relative. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct SceneAnchor { + pub pos: Point2D, + pub handle_in: Option, + pub handle_out: Option, + pub point_type: ScenePointType, +} + #[derive(Debug, Clone, PartialEq)] pub struct SceneNode { /// Stable node id (the `.op` schema id). Identity for hit-test / @@ -190,6 +212,10 @@ pub struct SceneNode { /// populated for `Path` (and any kind the painter walks as /// points). Empty otherwise. pub points: Vec, + /// Bezier anchors for `Path` nodes — parallel to `points` but + /// carrying the editable control handles + point type. Empty for + /// non-Path kinds. + pub path_anchors: Vec, /// Ellipse arc start angle in degrees. `None` = full ellipse. pub arc_start_angle: Option, /// Ellipse arc sweep angle in degrees. `None` = full ellipse. @@ -271,6 +297,7 @@ impl SceneNode { font_weight: 0, text_wrap: false, points: Vec::new(), + path_anchors: Vec::new(), arc_start_angle: None, arc_sweep_angle: None, arc_inner_radius: None, diff --git a/crates/op-pen-loader/src/adapter.rs b/crates/op-pen-loader/src/adapter.rs index b2accbf71..39783e206 100644 --- a/crates/op-pen-loader/src/adapter.rs +++ b/crates/op-pen-loader/src/adapter.rs @@ -334,6 +334,38 @@ fn absolutize_path_anchors(p: &mut NodePayload, path: &PathNode) { pt[0] = ox + (pt[0] - min_x) * sx; pt[1] = oy + (pt[1] - min_y) * sy; } + // Resolve bezier anchors into the same absolute frame — anchor + // positions track `points`, handle deltas scale by `(sx, sy)`. + if let Some(anchors) = &path.anchors { + p.path_anchors = anchors + .iter() + .map(|a| { + let ax = ox + (a.x as f32 - min_x) * sx; + let ay = oy + (a.y as f32 - min_y) * sy; + let resolve = |h: &jian_ops_schema::node::PenPathHandle| { + [ax + h.x as f32 * sx, ay + h.y as f32 * sy] + }; + crate::payload::AnchorPayload { + x: ax, + y: ay, + handle_in: a.handle_in.as_ref().map(resolve), + handle_out: a.handle_out.as_ref().map(resolve), + point_type: point_type_code(a.point_type.as_ref()), + } + }) + .collect(); + } +} + +/// Schema point-type → payload code (0 corner / 1 mirrored / 2 +/// independent). +fn point_type_code(pt: Option<&jian_ops_schema::node::PenPathPointType>) -> u8 { + use jian_ops_schema::node::PenPathPointType; + match pt { + Some(PenPathPointType::Mirrored) => 1, + Some(PenPathPointType::Independent) => 2, + _ => 0, + } } /// Replace `(x, y, w, h)` on `p` with the absolute scene-coord rect @@ -653,6 +685,7 @@ fn base_payload(base: &PenNodeBase, kind: &str) -> NodePayload { collapsed: false, fill_type: "solid".into(), points: Vec::new(), + path_anchors: Vec::new(), font_size: 0.0, font_weight: 0, text_wrap: false, diff --git a/crates/op-pen-loader/src/layout_scene.rs b/crates/op-pen-loader/src/layout_scene.rs index 1460b885b..f7a27e662 100644 --- a/crates/op-pen-loader/src/layout_scene.rs +++ b/crates/op-pen-loader/src/layout_scene.rs @@ -111,6 +111,7 @@ fn node_payload_to_scene(node: &NodePayload, var_table: &VariableTable) -> Scene .iter() .map(|p| Point2D::new(p[0], p[1])) .collect(), + path_anchors: node.path_anchors.iter().map(anchor_to_scene).collect(), arc_start_angle: node.arc_start_angle, arc_sweep_angle: node.arc_sweep_angle, arc_inner_radius: node.arc_inner_radius, @@ -125,6 +126,22 @@ fn node_payload_to_scene(node: &NodePayload, var_table: &VariableTable) -> Scene } } +/// Convert a payload path anchor into a scene anchor. +fn anchor_to_scene(a: &crate::payload::AnchorPayload) -> op_editor_ui::layout_scene::SceneAnchor { + use op_editor_ui::layout_scene::{SceneAnchor, ScenePointType}; + use op_editor_ui::Point2D; + SceneAnchor { + pos: Point2D::new(a.x, a.y), + handle_in: a.handle_in.map(|h| Point2D::new(h[0], h[1])), + handle_out: a.handle_out.map(|h| Point2D::new(h[0], h[1])), + point_type: match a.point_type { + 1 => ScenePointType::Mirrored, + 2 => ScenePointType::Independent, + _ => ScenePointType::Corner, + }, + } +} + /// Resolve a payload stroke into a scene stroke. The `$ref` stroke /// resolution parallels the fill path. fn scene_stroke( diff --git a/crates/op-pen-loader/src/payload.rs b/crates/op-pen-loader/src/payload.rs index 85f898a52..35cac81e7 100644 --- a/crates/op-pen-loader/src/payload.rs +++ b/crates/op-pen-loader/src/payload.rs @@ -74,6 +74,10 @@ pub struct NodePayload { pub fill_type: String, #[serde(default)] pub points: Vec<[f32; 2]>, + /// Path bezier anchors (absolute doc coords, handles resolved). + /// Parallel to `points` for `Path` nodes; empty otherwise. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub path_anchors: Vec, /// Text size in doc-px. 0 = use the renderer's default 13 px. /// Text-only. #[serde(default)] @@ -96,6 +100,22 @@ pub struct StrokePayload { pub width: f32, } +/// One path bezier anchor in absolute doc coords. `handle_in` / +/// `handle_out` are absolute control-point positions (already +/// resolved from the schema's anchor-relative deltas); `point_type` +/// is `0` corner / `1` mirrored / `2` independent. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AnchorPayload { + pub x: f32, + pub y: f32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub handle_in: Option<[f32; 2]>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub handle_out: Option<[f32; 2]>, + #[serde(default)] + pub point_type: u8, +} + /// Wrapper around `jian_ops_schema::load_str` that retries with the /// document's `version` field rewritten to "1.0" when the canonical /// loader rejects on an unrecognised major (e.g. the TS app's