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:
parent
b7bcd9db12
commit
79b51573dd
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue