mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 03:06:57 -06:00
Paths: anchor commands refuse a subpath that is not a whole number
path.curvatureEdit, path.removeAnchor, path.convertAnchor, path.reshapeSegment, path.split, path.setHandle and path.insertAnchor read subpath as an unsigned integer and fell back to 0 for any other value. A subpath of 1.5, -1 or "1" edited subpath 0 and the command reported success. path.split read anchor and segment with as_u64 too and treated any other value as absent, so a bad anchor beside a good segment split at the segment. These commands now refuse a subpath that is not a whole number of 0 or more with "missing integer `subpath`", the error path.curvatureEdit, path.removeAnchor, path.convertAnchor and path.reshapeSegment already give for a bad anchor or segment, and path.split refuses such an anchor or segment too. Without a subpath, or with null, they edit subpath 0 as before. The document and the undo history stay as they were after each error. The params text of the six commands that listed subpath as required now marks it optional. Docs: docs/mcp.md, docs/target-app-parity.md.
This commit is contained in:
@@ -53,7 +53,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Delete Anchor Point",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath, anchor} remove one anchor, re-fitting the curve",
|
||||
"{id, subpath?, anchor} remove one anchor, re-fitting the curve",
|
||||
has_doc,
|
||||
remove_anchor
|
||||
),
|
||||
@@ -71,7 +71,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Convert Anchor Point",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath, anchor, to: \"corner\"|\"smooth\", x?, y?} (smooth with x/y: out handle there, in handle mirrored; without: as the Curvature tool curves a point, along the circle through it and its neighbours, or at an open end one handle into the curve)",
|
||||
"{id, subpath?, anchor, to: \"corner\"|\"smooth\", x?, y?} (smooth with x/y: out handle there, in handle mirrored; without: as the Curvature tool curves a point, along the circle through it and its neighbours, or at an open end one handle into the curve)",
|
||||
has_doc,
|
||||
convert_anchor
|
||||
),
|
||||
@@ -80,7 +80,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reshape Segment",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath, segment, t, dx, dy, perpendicular?: bool} bend a segment so the point at t moves by (dx, dy); with perpendicular (Shift), both handles stand at right angles to the line between its anchors at one length, and the point at t lies as far from that line as the moved point does",
|
||||
"{id, subpath?, segment, t, dx, dy, perpendicular?: bool} bend a segment so the point at t moves by (dx, dy); with perpendicular (Shift), both handles stand at right angles to the line between its anchors at one length, and the point at t lies as far from that line as the moved point does",
|
||||
has_doc,
|
||||
reshape_segment
|
||||
),
|
||||
@@ -89,7 +89,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Split Path",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath, segment, t} or {id, subpath, anchor}: closed paths open there, open paths become two → {ids}",
|
||||
"{id, subpath?, segment, t} or {id, subpath?, anchor}: closed paths open there, open paths become two → {ids}",
|
||||
has_doc,
|
||||
split
|
||||
),
|
||||
@@ -215,6 +215,15 @@ fn usize_req(p: &Value, key: &str, cmd: &str) -> Result<usize> {
|
||||
p.get(key).and_then(Value::as_u64).map(|v| v as usize).ok_or_else(|| bad(cmd, format!("missing integer `{key}`")))
|
||||
}
|
||||
|
||||
/// The whole number of 0 or more that `key` holds, or `None` when `key` is absent or null. Any other
|
||||
/// value is [`usize_req`]'s error.
|
||||
pub(super) fn usize_opt(p: &Value, key: &str, cmd: &str) -> Result<Option<usize>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(_) => usize_req(p, key, cmd).map(Some),
|
||||
}
|
||||
}
|
||||
|
||||
fn path_mut(d: &mut Document, id: NodeId) -> Result<&mut PathData> {
|
||||
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
|
||||
match &mut n.kind {
|
||||
@@ -795,7 +804,7 @@ fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use vectorcraft_tools::draw2::{EndCurve, curvature_close, curvature_extend, curvature_insert, curvature_move};
|
||||
const C: &str = "path.curvatureEdit";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
|
||||
let at = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) {
|
||||
(Some(x), Some(y)) if x.is_finite() && y.is_finite() => Some(Point::new(x, y)),
|
||||
(None, None) => None,
|
||||
@@ -859,7 +868,7 @@ fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn remove_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.removeAnchor";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
|
||||
let ai = usize_req(p, "anchor", C)?;
|
||||
let removed = s.edit("Delete Anchor Point", |d, sel| {
|
||||
let sp = path_mut(d, id)?.subpaths.get(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
|
||||
@@ -966,7 +975,7 @@ fn remove_anchors_of(
|
||||
fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.convertAnchor";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
|
||||
let ai = usize_req(p, "anchor", C)?;
|
||||
let smooth = str_param(p, "to") == Some("smooth");
|
||||
let target = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) {
|
||||
@@ -1001,7 +1010,7 @@ fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.reshapeSegment";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
|
||||
let seg = usize_req(p, "segment", C)?;
|
||||
let t = f64_or(p, "t", 0.5).clamp(0.05, 0.95);
|
||||
let dv = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
|
||||
@@ -1026,9 +1035,9 @@ fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn split(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.split";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let anchor = p.get("anchor").and_then(Value::as_u64).map(|v| v as usize);
|
||||
let seg = p.get("segment").and_then(Value::as_u64).map(|v| v as usize);
|
||||
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
|
||||
let anchor = usize_opt(p, "anchor", C)?;
|
||||
let seg = usize_opt(p, "segment", C)?;
|
||||
if anchor.is_none() && seg.is_none() {
|
||||
return Err(bad(C, "need `anchor` or `segment` + `t`"));
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use vectorcraft_doc::{NodeId, NodeKind, Scaling};
|
||||
use vectorcraft_geom::{Anchor, AnchorKind, PathData, Point, SubPath, Vec2};
|
||||
|
||||
use super::create::anchor_from_json;
|
||||
use super::draw2::usize_opt;
|
||||
use super::pathops::Beside;
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
@@ -37,7 +38,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Move Direction Handle",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath, anchor, which: \"in\"|\"out\", x, y, independent?: bool}",
|
||||
"{id, subpath?, anchor, which: \"in\"|\"out\", x, y, independent?: bool}",
|
||||
has_doc,
|
||||
set_handle
|
||||
),
|
||||
@@ -104,7 +105,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
reshape
|
||||
),
|
||||
cmd!("path.insertAnchor", "Add Anchor Point", [], None, "{id, subpath, segment, t: 0..1}", has_doc, insert_anchor),
|
||||
cmd!("path.insertAnchor", "Add Anchor Point", [], None, "{id, subpath?, segment, t: 0..1}", has_doc, insert_anchor),
|
||||
cmd!(
|
||||
"path.cutAtAnchors",
|
||||
"Cut Path at Selected Anchor Points",
|
||||
@@ -215,7 +216,7 @@ pub(crate) fn transform_anchors(s: &mut Session, label: &str, xf: vectorcraft_ge
|
||||
|
||||
fn set_handle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("path.setHandle", "missing id"))?;
|
||||
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let si = usize_opt(p, "subpath", "path.setHandle")?.unwrap_or(0);
|
||||
let ai = p.get("anchor").and_then(Value::as_u64).ok_or_else(|| bad("path.setHandle", "missing anchor"))? as usize;
|
||||
let out = str_param(p, "which") != Some("in");
|
||||
let pos = Point::new(f64_req(p, "x", "path.setHandle")?, f64_req(p, "y", "path.setHandle")?);
|
||||
@@ -715,7 +716,7 @@ fn reshape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn insert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("path.insertAnchor", "missing id"))?;
|
||||
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let si = usize_opt(p, "subpath", "path.insertAnchor")?.unwrap_or(0);
|
||||
let seg = p.get("segment").and_then(Value::as_u64).ok_or_else(|| bad("path.insertAnchor", "missing segment"))? as usize;
|
||||
let t = f64_or(p, "t", 0.5).clamp(0.0, 1.0);
|
||||
let idx = s.edit("Add Anchor Point", |d, _| {
|
||||
|
||||
@@ -867,6 +867,131 @@ fn draw2_commands_reject_bad_params() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A path of two open subpaths, each of three anchors from x 100 to x 300 with a middle anchor that
|
||||
/// has two handles: subpath 0 at y 100, subpath 1 at y 300.
|
||||
fn two_subpaths(s: &mut Session) -> NodeId {
|
||||
let id = curve(s);
|
||||
let sub = |y: f64| {
|
||||
let anchors = json!([
|
||||
{"x": 100, "y": y, "out": [130, y - 30.0]},
|
||||
{"x": 200, "y": y, "in": [170, y - 30.0], "out": [230, y - 30.0]},
|
||||
{"x": 300, "y": y, "in": [270, y - 30.0]},
|
||||
]);
|
||||
json!({"anchors": anchors, "closed": false})
|
||||
};
|
||||
s.execute("path.setAnchors", &json!({"id": id.0, "subpaths": [sub(100.0), sub(300.0)]})).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
/// Runs `cmd` with `p` and `key` set to each of 1.5, -1 and "1": each call fails with an error that
|
||||
/// names `key`, and the document and the undo history stay as they were.
|
||||
fn refuses_an_index_that_is_not_whole(s: &mut Session, cmd: &str, p: &serde_json::Value, key: &str) {
|
||||
for v in [json!(1.5), json!(-1), json!("1")] {
|
||||
let mut p = p.clone();
|
||||
p[key] = v;
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let undo = undo_steps(s);
|
||||
let Err(e) = s.execute(cmd, &p) else { panic!("{cmd} {p} succeeded") };
|
||||
assert!(e.to_string().contains(&format!("`{key}`")), "{cmd} {p}: {e}");
|
||||
assert!(s.doc().unwrap().doc == before, "{cmd} {p} changed the document");
|
||||
assert_eq!(undo_steps(s), undo, "{cmd} {p} left an undo step");
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `cmd` with `p` on path `id` (made by [`two_subpaths`]) with `subpath: 1`, without `subpath`
|
||||
/// and with `subpath: null`, undoing each: the first changes subpath 1 alone, and the others change
|
||||
/// subpath 0 alone.
|
||||
fn edits_the_subpath_given(s: &mut Session, cmd: &str, p: &serde_json::Value, id: NodeId) {
|
||||
let before = path(s, id).subpaths;
|
||||
for (si, p) in [(1, with(p, json!({"subpath": 1}))), (0, p.clone()), (0, with(p, json!({"subpath": null})))] {
|
||||
s.execute(cmd, &p).unwrap();
|
||||
let after = path(s, id).subpaths;
|
||||
assert_eq!(after.len(), 2, "{cmd} {p}");
|
||||
assert!(after[si] != before[si] && after[1 - si] == before[1 - si], "{cmd} {p}: {after:?}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(path(s, id).subpaths, before, "{cmd} {p}: undo");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curvature_edit_refuses_a_subpath_that_is_not_a_whole_number() {
|
||||
let mut s = session();
|
||||
let id = two_subpaths(&mut s);
|
||||
let p = json!({"id": id.0, "op": "move", "anchor": 1, "x": 200, "y": 150});
|
||||
refuses_an_index_that_is_not_whole(&mut s, "path.curvatureEdit", &p, "subpath");
|
||||
edits_the_subpath_given(&mut s, "path.curvatureEdit", &p, id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_anchor_refuses_a_subpath_that_is_not_a_whole_number() {
|
||||
let mut s = session();
|
||||
let id = two_subpaths(&mut s);
|
||||
let p = json!({"id": id.0, "anchor": 1});
|
||||
refuses_an_index_that_is_not_whole(&mut s, "path.removeAnchor", &p, "subpath");
|
||||
edits_the_subpath_given(&mut s, "path.removeAnchor", &p, id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_anchor_refuses_a_subpath_that_is_not_a_whole_number() {
|
||||
let mut s = session();
|
||||
let id = two_subpaths(&mut s);
|
||||
let p = json!({"id": id.0, "anchor": 1, "to": "corner"});
|
||||
refuses_an_index_that_is_not_whole(&mut s, "path.convertAnchor", &p, "subpath");
|
||||
edits_the_subpath_given(&mut s, "path.convertAnchor", &p, id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_segment_refuses_a_subpath_that_is_not_a_whole_number() {
|
||||
let mut s = session();
|
||||
let id = two_subpaths(&mut s);
|
||||
let p = json!({"id": id.0, "segment": 0, "t": 0.5, "dx": 0, "dy": 20});
|
||||
refuses_an_index_that_is_not_whole(&mut s, "path.reshapeSegment", &p, "subpath");
|
||||
edits_the_subpath_given(&mut s, "path.reshapeSegment", &p, id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_refuses_a_subpath_anchor_or_segment_that_is_not_a_whole_number() {
|
||||
// A bad `anchor` beside a good `segment` is refused, and so is a bad `segment` beside a good
|
||||
// `anchor`.
|
||||
let mut s = session();
|
||||
let id = two_subpaths(&mut s);
|
||||
let at_anchor = json!({"id": id.0, "anchor": 1});
|
||||
let at_segment = json!({"id": id.0, "segment": 0, "t": 0.5});
|
||||
refuses_an_index_that_is_not_whole(&mut s, "path.split", &at_anchor, "subpath");
|
||||
refuses_an_index_that_is_not_whole(&mut s, "path.split", &at_segment, "subpath");
|
||||
refuses_an_index_that_is_not_whole(&mut s, "path.split", &at_segment, "anchor");
|
||||
refuses_an_index_that_is_not_whole(&mut s, "path.split", &at_anchor, "segment");
|
||||
let before = path(&s, id).subpaths;
|
||||
for (si, p) in [(1, with(&at_anchor, json!({"subpath": 1}))), (0, at_anchor.clone()), (0, with(&at_anchor, json!({"subpath": null})))] {
|
||||
s.execute("path.split", &p).unwrap();
|
||||
let after = path(&s, id).subpaths;
|
||||
// The subpath split becomes two of two anchors each, in its place.
|
||||
let kept = if si == 1 { &after[0] } else { &after[2] };
|
||||
assert!(after.len() == 3 && after[si].anchors.len() == 2 && after[si + 1].anchors.len() == 2, "{p}: {after:?}");
|
||||
assert_eq!(kept, &before[1 - si], "{p}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(path(&s, id).subpaths, before, "{p}: undo");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_handle_refuses_a_subpath_that_is_not_a_whole_number() {
|
||||
let mut s = session();
|
||||
let id = two_subpaths(&mut s);
|
||||
let p = json!({"id": id.0, "anchor": 0, "which": "out", "x": 140, "y": 250});
|
||||
refuses_an_index_that_is_not_whole(&mut s, "path.setHandle", &p, "subpath");
|
||||
edits_the_subpath_given(&mut s, "path.setHandle", &p, id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_anchor_refuses_a_subpath_that_is_not_a_whole_number() {
|
||||
let mut s = session();
|
||||
let id = two_subpaths(&mut s);
|
||||
let p = json!({"id": id.0, "segment": 0, "t": 0.5});
|
||||
refuses_an_index_that_is_not_whole(&mut s, "path.insertAnchor", &p, "subpath");
|
||||
edits_the_subpath_given(&mut s, "path.insertAnchor", &p, id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_commands_refuse_a_delta_or_point_of_1e9_pt_or_more() {
|
||||
// `path.reshapeSegment` refuses a delta of 1e9 pt or longer, and `path.convertAnchor` and
|
||||
|
||||
@@ -1962,6 +1962,10 @@ does. `path.convertAnchor` (its `x`, `y`) and `path.freehand` (each of its `poin
|
||||
path by its control points when one of them has an x or y that is infinite or larger in size than `f64::MAX / 16`
|
||||
(about 1.1e307), so an edit that leaves such a path selected, `path.create` included, fails with "result would exceed
|
||||
the canvas (coordinates out of range)".
|
||||
`path.curvatureEdit`, `path.removeAnchor`, `path.convertAnchor`, `path.reshapeSegment`, `path.split`,
|
||||
`path.setHandle` and `path.insertAnchor` edit subpath 0 when `subpath` is absent or null, and refuse a `subpath` that
|
||||
is not a whole number of 0 or more, such as 1.5, -1 or "1" ("missing integer `subpath`"); `path.split` refuses such
|
||||
an `anchor` or `segment` in the same way. The document does not change and no undo step is recorded.
|
||||
|
||||
A `pointer_gesture` with `mods: {cmd: true}` held at the press, with any tool but the selection tools (Command on
|
||||
macOS, Ctrl elsewhere), drags with the selection tool chosen last (`select_tool`), or Direct Selection with the Pen,
|
||||
|
||||
@@ -538,6 +538,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **Direct Selection (#1278):** clicking a segment picks its connection, with hollow endpoint anchors and only that curve's direction handles. Delete leaves the neighboring curves and appearance intact, opens/splits contours in one undo step, and another Delete removes the remaining selected path. A whole-path drag and deliberate anchor selection keep their existing behavior; egui frames and native control/art checked, OS mouse/visual QA pending. Where anchors of overlapping objects coincide, a click or drag takes the anchor of the selected object, also when another object lies over it (#1509, fixes #1508). Delete removes every selected anchor of a closed path (it used to remove the wrong ones), removes objects selected whole along with the selected anchors of others, and keeps the remainder selected so a second Delete removes it (#1674). In isolation mode Direct Selection and Group Selection pick only inside the isolated object (#1666). Escape cancels a Direct Selection drag (#1803, #1743). Double-clicking type (including type nested inside a group) switches to the Type tool and puts the caret at the clicked character.
|
||||
A path segment with a control point whose x or y is infinite or larger in size than `f64::MAX / 16` is measured by its control points, so its bounds hold the whole curve and the canvas check refuses an edit that leaves such a path selected (`path.create` included).
|
||||
With Direct Selection, the Pen and the anchor tools, a group, compound path or clip group selected whole shows the anchors of its paths filled, as a path selected whole does; the direction handles still show only on a path that is selected itself. Public references: [Adjust anchor point, handle, and bounding box display size](https://web.archive.org/web/20251114182206/https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/learn-drawing-basics/adjust-anchor-point-handle-and-bounding-box-display-size.html) and [Drawing basics](https://web.archive.org/web/20231130064834/https://helpx.adobe.com/illustrator/using/drawing-basics.html) (archived copies).
|
||||
`path.curvatureEdit`, `path.removeAnchor`, `path.convertAnchor`, `path.reshapeSegment`, `path.split`, `path.setHandle` and `path.insertAnchor` refuse a `subpath` that is not a whole number of 0 or more, and `path.split` refuses such an `anchor` or `segment`; without a `subpath` they edit subpath 0.
|
||||
`path.reshapeSegment` refuses a delta of 1e9 pt or longer, as `object.blend.spine.reshapeSegment` does, and `path.convertAnchor` and `path.freehand` refuse a point whose x or y is 1e9 pt or more in size.
|
||||
- **Group Selection (#2441):** a click picks one path of a compound path (its outline within the pick tolerance, else the innermost path whose area holds the click: the outer shape or a hole), Shift-click adds another, and a click on a selected path adds the compound path, as a click on a group member climbs to its group. A path selected this way moves, rotates and scales on its own; paint still goes to the compound path.
|
||||
With Direct Selection, Shift pressed during the drag of a curved segment keeps both of its handles at right angles to the line between its anchors, on the same side and at the same length (`path.reshapeSegment {perpendicular}`). Shift held while any segment is dragged with the Anchor Point tool or the Pen with Alt does the same. A selected blend spine's segment dragged with the Anchor Point tool, or a curved one dragged with Direct Selection, bends the same way (`object.blend.spine.reshapeSegment`).
|
||||
|
||||
Reference in New Issue
Block a user