feat(editor): pen anchor bezier-handle data model + setters

Stage 1 of pen bezier-handle editing — the data + command layer.

- op-editor-core: `EditorState::set_path_anchor_handle` (set / clear
  an anchor's in/out handle; a `Mirrored` anchor keeps both handles
  collinear) + `set_path_anchor_point_type` (switching to `Mirrored`
  snaps the handles collinear). New `PathHandleSide` enum.
- op-pen-loader: `AnchorPayload` (absolute-coord anchor + resolved
  handles + point-type code) on `NodePayload.path_anchors`;
  `absolutize_path_anchors` now resolves the schema's anchor-relative
  handle deltas into the same absolute frame as `points`.
- op-editor-ui: `SceneAnchor` + `ScenePointType` on
  `SceneNode.path_anchors` so the painter + host can read the
  editable handles.

op-editor-core 234 / op-pen-loader 21 / op-editor-ui 145 tests green
(+3 handle-setter units).

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Kayshen-X 2026-05-17 17:33:44 +08:00
parent b7bcd9db12
commit 79b51573dd
6 changed files with 250 additions and 1 deletions

View file

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

View file

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

View file

@ -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<Point2D>,
pub handle_out: Option<Point2D>,
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<Point2D>,
/// 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<SceneAnchor>,
/// Ellipse arc start angle in degrees. `None` = full ellipse.
pub arc_start_angle: Option<f32>,
/// 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,

View file

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

View file

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

View file

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