feat(tools): The Pen continues an open path from either end point
As in InDesign, the Pen over an end point of an open path (selected, or under the pointer) shows a continue cursor; clicking it carries on drawing that path, new anchors going on its subpath, and clicking its other end closes it. Dragging from the end point first reshapes its direction lines. From the first anchor the subpath is reversed first. - engine: path.reverse (Object > Paths > Reverse Path), path.setAnchor; path.appendAnchor and path.close take a subpath, and appendAnchor refuses a closed subpath - tools: Cursor::PenContinue Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d020b4e85d
commit
c35515c54d
|
|
@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
|
|||
|
||||
Newest first.
|
||||
|
||||
- **2026-10-12** · The Pen tool continues an open path from either end point, as in InDesign: the cursor shows it over an end point, a click there carries on drawing that path, a drag first reshapes the end point's direction lines, and the other end closes it. Object › Paths › Reverse Path (`path.reverse`); `path.setAnchor`; `path.appendAnchor` and `path.close` take a subpath. No readiness percentage change.
|
||||
- **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change.
|
||||
- **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change.
|
||||
- **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change.
|
||||
|
|
|
|||
|
|
@ -595,8 +595,34 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
step_and_repeat
|
||||
),
|
||||
cmd!("path.create", "Create Path", [], None, "{spread?, anchors: [{p:[x,y], in?:[x,y], out?:[x,y]}], closed?: bool}", has_doc, path_create),
|
||||
cmd!("path.appendAnchor", "Add Anchor", [], None, "{id, anchor: {p, in?, out?}} (spread coords)", has_doc, path_append),
|
||||
cmd!("path.close", "Close Path", ["Object", "Paths"], None, "{id}", has_doc, path_close),
|
||||
cmd!(
|
||||
"path.appendAnchor",
|
||||
"Add Anchor",
|
||||
[],
|
||||
None,
|
||||
"{id, anchor: {p, in?, out?} (spread coords), subpath?: index (default the last)} — after the subpath's last anchor",
|
||||
has_doc,
|
||||
path_append
|
||||
),
|
||||
cmd!(
|
||||
"path.setAnchor",
|
||||
"Set Anchor",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath?: index (default the last), anchor: index|\"first\"|\"last\", p?, in?, out? (spread coords)} — moves an anchor or its direction lines; with both lines given and collinear it becomes smooth",
|
||||
has_doc,
|
||||
path_set_anchor
|
||||
),
|
||||
cmd!(
|
||||
"path.reverse",
|
||||
"Reverse Path",
|
||||
["Object", "Paths"],
|
||||
None,
|
||||
"{ids?, subpath?: index (only that subpath of the first target)} — the anchors run the other way (the end point becomes the start)",
|
||||
has_selection,
|
||||
path_reverse
|
||||
),
|
||||
cmd!("path.close", "Close Path", ["Object", "Paths"], None, "{id, subpath?: index (default every subpath)}", has_doc, path_close),
|
||||
cmd!("path.moveAnchors", "Move Anchors", [], None, "{id, anchors: [[subpath, index]], dx, dy, handle?: in|out}", has_doc, path_move_anchors),
|
||||
cmd!(
|
||||
"object.align",
|
||||
|
|
@ -1935,11 +1961,20 @@ fn path_create(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
fn path_append(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = super::id_param(p, "id").ok_or_else(|| bad("path.appendAnchor", "missing id"))?;
|
||||
let a = p.get("anchor").and_then(anchor_from).ok_or_else(|| bad("path.appendAnchor", "missing anchor"))?;
|
||||
let subpath = p.get("subpath").and_then(Value::as_u64);
|
||||
s.edit(|d, _| {
|
||||
let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
||||
let inv = (d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default()).inverse();
|
||||
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
||||
let sp = it.path.subpaths.last_mut().ok_or_else(|| bad("path.appendAnchor", "empty path"))?;
|
||||
let sp = match subpath {
|
||||
Some(i) => {
|
||||
usize::try_from(i).ok().and_then(|i| it.path.subpaths.get_mut(i)).ok_or_else(|| bad("path.appendAnchor", "no such subpath"))?
|
||||
}
|
||||
None => it.path.subpaths.last_mut().ok_or_else(|| bad("path.appendAnchor", "empty path"))?,
|
||||
};
|
||||
if sp.closed {
|
||||
return Err(bad("path.appendAnchor", "the subpath is closed"));
|
||||
}
|
||||
sp.anchors.push(a.transform(inv));
|
||||
if it.shape == Shape::GraphicLine {
|
||||
it.shape = Shape::Path;
|
||||
|
|
@ -1948,12 +1983,103 @@ fn path_append(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
})
|
||||
}
|
||||
|
||||
fn path_set_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "path.setAnchor";
|
||||
let id = super::id_param(p, "id").ok_or_else(|| bad(ID, "missing id"))?;
|
||||
let pt = |k: &str| -> Option<Point> {
|
||||
let a = p.get(k)?.as_array()?;
|
||||
let q = Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?);
|
||||
(q.x.is_finite() && q.y.is_finite()).then_some(q)
|
||||
};
|
||||
let (new_p, new_in, new_out) = (pt("p"), pt("in"), pt("out"));
|
||||
if new_p.is_none() && new_in.is_none() && new_out.is_none() {
|
||||
return Err(bad(ID, "give p, in or out"));
|
||||
}
|
||||
let subpath = p.get("subpath").and_then(Value::as_u64);
|
||||
let which = p.get("anchor").cloned().unwrap_or(Value::Null);
|
||||
s.edit(|d, _| {
|
||||
let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
||||
let inv = (d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default()).inverse();
|
||||
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
||||
if it.locked {
|
||||
return Err(bad(ID, "the path is locked"));
|
||||
}
|
||||
let sp = match subpath {
|
||||
Some(i) => usize::try_from(i).ok().and_then(|i| it.path.subpaths.get_mut(i)),
|
||||
None => it.path.subpaths.last_mut(),
|
||||
}
|
||||
.ok_or_else(|| bad(ID, "no such subpath"))?;
|
||||
let n = sp.anchors.len();
|
||||
let ai = match &which {
|
||||
Value::String(w) if w == "first" => 0,
|
||||
Value::String(w) if w == "last" => n.saturating_sub(1),
|
||||
v => v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(ID, "anchor: an index, \"first\" or \"last\""))?,
|
||||
};
|
||||
let a = sp.anchors.get_mut(ai).ok_or_else(|| bad(ID, "no such anchor"))?;
|
||||
if let Some(q) = new_p {
|
||||
let v = inv * q - a.p;
|
||||
a.p += v;
|
||||
a.h_in += v;
|
||||
a.h_out += v;
|
||||
}
|
||||
if let Some(q) = new_in {
|
||||
a.h_in = inv * q;
|
||||
}
|
||||
if let Some(q) = new_out {
|
||||
a.h_out = inv * q;
|
||||
}
|
||||
if new_in.is_some() && new_out.is_some() {
|
||||
let (i, o) = (a.h_in - a.p, a.h_out - a.p);
|
||||
let collinear = (i.x * o.y - i.y * o.x).abs() <= 1e-6 * i.hypot().max(1.0) * o.hypot().max(1.0) && i.dot(o) <= 0.0;
|
||||
a.kind = if collinear { designcraft_geom::AnchorKind::Smooth } else { designcraft_geom::AnchorKind::Corner };
|
||||
}
|
||||
ok()
|
||||
})
|
||||
}
|
||||
|
||||
fn path_reverse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
let only = p.get("subpath").and_then(Value::as_u64).map(|i| usize::try_from(i).unwrap_or(usize::MAX));
|
||||
if let (Some(i), Some(id)) = (only, ids.first())
|
||||
&& s.doc()?.doc.item(*id).is_none_or(|it| it.path.subpaths.get(i).is_none())
|
||||
{
|
||||
return Err(bad("path.reverse", "no such subpath"));
|
||||
}
|
||||
let ids = if only.is_some() { ids.into_iter().take(1).collect() } else { ids };
|
||||
s.edit(|d, _| {
|
||||
let mut reversed = 0;
|
||||
for id in &ids {
|
||||
let Some(it) = d.item_mut(*id) else { continue };
|
||||
if it.locked || it.is_group() {
|
||||
continue;
|
||||
}
|
||||
for (i, sp) in it.path.subpaths.iter_mut().enumerate() {
|
||||
if only.is_some_and(|o| o != i) {
|
||||
continue;
|
||||
}
|
||||
sp.anchors.reverse();
|
||||
for a in &mut sp.anchors {
|
||||
std::mem::swap(&mut a.h_in, &mut a.h_out);
|
||||
}
|
||||
}
|
||||
reversed += 1;
|
||||
}
|
||||
Ok(json!({"reversed": reversed}))
|
||||
})
|
||||
}
|
||||
|
||||
fn path_close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = super::id_param(p, "id").ok_or_else(|| bad("path.close", "missing id"))?;
|
||||
let only = p.get("subpath").and_then(Value::as_u64).map(|i| usize::try_from(i).unwrap_or(usize::MAX));
|
||||
s.edit(|d, _| {
|
||||
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
||||
for sp in &mut it.path.subpaths {
|
||||
sp.closed = true;
|
||||
if only.is_some_and(|i| i >= it.path.subpaths.len()) {
|
||||
return Err(bad("path.close", "no such subpath"));
|
||||
}
|
||||
for (i, sp) in it.path.subpaths.iter_mut().enumerate() {
|
||||
if only.is_none_or(|o| o == i) {
|
||||
sp.closed = true;
|
||||
}
|
||||
}
|
||||
if it.shape == Shape::GraphicLine {
|
||||
it.shape = Shape::Path;
|
||||
|
|
@ -2813,3 +2939,61 @@ mod swatch_name_tests {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod path_edit_tests {
|
||||
use designcraft_doc::ItemId;
|
||||
use designcraft_geom::Point;
|
||||
use serde_json::json;
|
||||
|
||||
fn session_with_path() -> (crate::Session, u64) {
|
||||
let mut s = crate::Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let id = s
|
||||
.execute(
|
||||
"path.create",
|
||||
&json!({"anchors": [{"p": [100, 100]}, {"p": [200, 100], "in": [170, 80], "out": [230, 120]}, {"p": [300, 200]}]}),
|
||||
)
|
||||
.unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
(s, id)
|
||||
}
|
||||
|
||||
fn anchors(s: &crate::Session, id: u64) -> Vec<designcraft_geom::Anchor> {
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let it = d.item(ItemId(id)).unwrap();
|
||||
let xf = d.parent_xf(&d.find(ItemId(id)).unwrap()) * it.xf;
|
||||
it.path.subpaths[0].anchors.iter().map(|a| a.transform(xf)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverse_path_swaps_ends_and_direction_lines() {
|
||||
let (mut s, id) = session_with_path();
|
||||
s.execute("path.reverse", &json!({"ids": [id]})).unwrap();
|
||||
let a = anchors(&s, id);
|
||||
assert_eq!(a.iter().map(|a| a.p).collect::<Vec<_>>(), [Point::new(300.0, 200.0), Point::new(200.0, 100.0), Point::new(100.0, 100.0)]);
|
||||
assert_eq!((a[1].h_in, a[1].h_out), (Point::new(230.0, 120.0), Point::new(170.0, 80.0)));
|
||||
assert!(s.execute("path.reverse", &json!({"ids": [id], "subpath": 3})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchors_go_on_the_named_open_subpath_and_set_anchor_reshapes_one() {
|
||||
let (mut s, id) = session_with_path();
|
||||
s.execute("path.appendAnchor", &json!({"id": id, "subpath": 0, "anchor": {"p": [400, 250]}})).unwrap();
|
||||
assert_eq!(anchors(&s, id).last().unwrap().p, Point::new(400.0, 250.0));
|
||||
assert!(s.execute("path.appendAnchor", &json!({"id": id, "subpath": 1, "anchor": {"p": [0, 0]}})).is_err());
|
||||
// Direction lines on the last anchor: collinear and opposite make it smooth.
|
||||
s.execute("path.setAnchor", &json!({"id": id, "anchor": "last", "in": [380, 230], "out": [420, 270]})).unwrap();
|
||||
let last = *anchors(&s, id).last().unwrap();
|
||||
assert_eq!((last.h_in, last.h_out, last.kind), (Point::new(380.0, 230.0), Point::new(420.0, 270.0), designcraft_geom::AnchorKind::Smooth));
|
||||
s.execute("path.setAnchor", &json!({"id": id, "anchor": 0, "p": [90, 110]})).unwrap();
|
||||
assert_eq!(anchors(&s, id)[0].p, Point::new(90.0, 110.0));
|
||||
assert!(s.execute("path.setAnchor", &json!({"id": id, "anchor": 9, "p": [0, 0]})).is_err());
|
||||
assert!(s.execute("path.setAnchor", &json!({"id": id, "anchor": "last"})).is_err(), "nothing to set");
|
||||
// Closing one subpath; then it takes no more anchors.
|
||||
s.execute("path.close", &json!({"id": id, "subpath": 0})).unwrap();
|
||||
assert!(s.execute("path.appendAnchor", &json!({"id": id, "anchor": {"p": [0, 0]}})).is_err());
|
||||
assert!(s.execute("path.close", &json!({"id": id, "subpath": 4})).is_err());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -335,6 +335,8 @@ pub enum Cursor {
|
|||
ResizeNeSw,
|
||||
Rotate,
|
||||
Pen,
|
||||
/// The Pen over an open path's end point: a click continues the path.
|
||||
PenContinue,
|
||||
Text,
|
||||
Hand,
|
||||
HandGrab,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
//! Pen tool (P): click for corner points, drag for smooth points, click the first point to close,
|
||||
//! Enter/Esc (or switching tools) to finish an open path. Each anchor is its own undo step, as in
|
||||
//! InDesign.
|
||||
//! InDesign. Clicking an end point of an open path (the cursor shows it) continues that path from
|
||||
//! there; dragging from it first reshapes its direction lines.
|
||||
|
||||
use designcraft_doc::SpreadRef;
|
||||
use designcraft_doc::{ItemId, SpreadRef};
|
||||
use designcraft_geom::{BezPath, Point};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
|
|
@ -19,6 +20,45 @@ pub struct PenTool {
|
|||
hover: Point,
|
||||
/// Smart guides in canvas coordinates, as `snap_point` returned them.
|
||||
guides: Vec<Overlay>,
|
||||
/// Continuing an existing path: the subpath the new anchors go on.
|
||||
subpath: Option<usize>,
|
||||
/// The press was on the continued path's end point: a drag reshapes its direction lines
|
||||
/// instead of adding an anchor.
|
||||
editing_end: bool,
|
||||
/// The press on the end point has been dragged.
|
||||
end_moved: bool,
|
||||
}
|
||||
|
||||
/// An end point of an open path under canvas point `p`: (item, subpath, whether it is the last
|
||||
/// anchor). The selection is checked first, then the frontmost item under the pointer.
|
||||
fn endpoint_at(cx: &ToolContext, p: Point) -> Option<(ItemId, usize, bool)> {
|
||||
let tol = cx.tol(6.0);
|
||||
let mut ids = cx.selection.items.clone();
|
||||
if let Some((_, id)) = cx.hit(p)
|
||||
&& !ids.contains(&id)
|
||||
{
|
||||
ids.push(id);
|
||||
}
|
||||
for id in ids {
|
||||
let Some(it) = cx.doc.item(id) else { continue };
|
||||
if it.locked || it.is_group() || !matches!(it.content, designcraft_doc::Content::Unassigned) {
|
||||
continue;
|
||||
}
|
||||
let Some(xf) = cx.item_canvas_xf(id).map(|m| m * it.xf) else { continue };
|
||||
for (si, sp) in it.path.subpaths.iter().enumerate() {
|
||||
if sp.closed {
|
||||
continue;
|
||||
}
|
||||
let (Some(first), Some(last)) = (sp.anchors.first(), sp.anchors.last()) else { continue };
|
||||
if (xf * last.p - p).hypot() <= tol {
|
||||
return Some((id, si, true));
|
||||
}
|
||||
if (xf * first.p - p).hypot() <= tol {
|
||||
return Some((id, si, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn anchor_json(a: &(Point, Point, Point)) -> Value {
|
||||
|
|
@ -32,9 +72,35 @@ impl PenTool {
|
|||
self.anchors.clear();
|
||||
self.dragging = false;
|
||||
self.guides.clear();
|
||||
self.subpath = None;
|
||||
self.editing_end = false;
|
||||
self.end_moved = false;
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Start continuing subpath `si` of `id` from its last (`at_end`) or first anchor: the anchors
|
||||
/// so far are its other end and the end point, in spread coordinates.
|
||||
fn continue_from(&mut self, cx: &ToolContext, id: ItemId, si: usize, at_end: bool) -> Vec<Action> {
|
||||
let (Some(it), Some(loc)) = (cx.doc.item(id), cx.doc.find(id)) else { return vec![] };
|
||||
let Some(sp) = it.path.subpaths.get(si) else { return vec![] };
|
||||
let (Some(first), Some(last)) = (sp.anchors.first(), sp.anchors.last()) else { return vec![] };
|
||||
let xf = cx.doc.parent_xf(&loc) * it.xf;
|
||||
let at = |a: &designcraft_geom::Anchor, flip: bool| {
|
||||
let (i, o) = if flip { (a.h_out, a.h_in) } else { (a.h_in, a.h_out) };
|
||||
(xf * a.p, xf * i, xf * o)
|
||||
};
|
||||
// From the first anchor the path is reversed first, so the new anchors follow on.
|
||||
let (other, end) = if at_end { (at(first, false), at(last, false)) } else { (at(last, true), at(first, true)) };
|
||||
self.path = Some(id.0);
|
||||
self.subpath = Some(si);
|
||||
self.spread = Some(loc.spread);
|
||||
self.anchors = vec![other, end];
|
||||
self.editing_end = true;
|
||||
self.end_moved = false;
|
||||
self.dragging = true;
|
||||
if at_end { vec![] } else { vec![Action::Exec("path.reverse".into(), json!({"ids": [id.0], "subpath": si}))] }
|
||||
}
|
||||
|
||||
/// Shift constrains first. The stored point is the snapped spread point. Guides are already canvas.
|
||||
fn snap_click(&mut self, cx: &ToolContext, spread: SpreadRef, mut p: Point, shift: bool) -> Point {
|
||||
if shift && let Some(last) = self.anchors.last() {
|
||||
|
|
@ -53,7 +119,13 @@ impl PenTool {
|
|||
let anchors: Vec<Value> = self.anchors.iter().map(anchor_json).collect();
|
||||
vec![Action::Exec("path.create".into(), json!({"spread": spread_json(sr), "anchors": anchors, "closed": false, "notifyPen": true}))]
|
||||
}
|
||||
Some(id) => vec![Action::Exec("path.appendAnchor".into(), json!({"id": id, "anchor": anchor_json(last)}))],
|
||||
Some(id) => {
|
||||
let mut p = json!({"id": id, "anchor": anchor_json(last)});
|
||||
if let Some(si) = self.subpath {
|
||||
p["subpath"] = json!(si);
|
||||
}
|
||||
vec![Action::Exec("path.appendAnchor".into(), p)]
|
||||
}
|
||||
None => vec![],
|
||||
}
|
||||
}
|
||||
|
|
@ -93,12 +165,24 @@ impl Tool for PenTool {
|
|||
&& (first.0 - sp).hypot() <= cx.tol(6.0)
|
||||
{
|
||||
let out = match self.path {
|
||||
Some(id) => vec![Action::Exec("path.close".into(), json!({"id": id}))],
|
||||
Some(id) => {
|
||||
let mut p = json!({"id": id});
|
||||
if let Some(si) = self.subpath {
|
||||
p["subpath"] = json!(si);
|
||||
}
|
||||
vec![Action::Exec("path.close".into(), p)]
|
||||
}
|
||||
None => vec![],
|
||||
};
|
||||
self.finish();
|
||||
return out;
|
||||
}
|
||||
// On an open path's end point: continue that path.
|
||||
if self.anchors.is_empty()
|
||||
&& let Some((id, si, at_end)) = endpoint_at(cx, ev.pos)
|
||||
{
|
||||
return self.continue_from(cx, id, si, at_end);
|
||||
}
|
||||
let p = self.snap_click(cx, snap_spread, sp, ev.mods.shift);
|
||||
if self.anchors.is_empty() {
|
||||
self.spread = Some(sr);
|
||||
|
|
@ -108,6 +192,7 @@ impl Tool for PenTool {
|
|||
vec![]
|
||||
}
|
||||
PointerKind::Drag => {
|
||||
self.end_moved |= self.editing_end;
|
||||
if let Some(a) = self.anchors.last_mut() {
|
||||
// Smooth point: out handle follows the pointer, in handle mirrors it.
|
||||
a.2 = sp;
|
||||
|
|
@ -120,6 +205,17 @@ impl Tool for PenTool {
|
|||
return vec![];
|
||||
}
|
||||
self.dragging = false;
|
||||
// The continued path's end point: a drag set its direction lines.
|
||||
if std::mem::take(&mut self.editing_end) {
|
||||
let (Some(id), Some(si), Some(&(p, h_in, h_out))) = (self.path, self.subpath, self.anchors.last()) else { return vec![] };
|
||||
if !std::mem::take(&mut self.end_moved) || (h_out - p).hypot() < 1e-9 {
|
||||
return vec![];
|
||||
}
|
||||
return vec![Action::Exec(
|
||||
"path.setAnchor".into(),
|
||||
json!({"id": id, "subpath": si, "anchor": "last", "in": [h_in.x, h_in.y], "out": [h_out.x, h_out.y]}),
|
||||
)];
|
||||
}
|
||||
self.commit_anchor()
|
||||
}
|
||||
_ => vec![],
|
||||
|
|
@ -165,8 +261,8 @@ impl Tool for PenTool {
|
|||
out
|
||||
}
|
||||
|
||||
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
|
||||
Cursor::Pen
|
||||
fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor {
|
||||
if self.anchors.is_empty() && endpoint_at(cx, p).is_some() { Cursor::PenContinue } else { Cursor::Pen }
|
||||
}
|
||||
|
||||
fn busy(&self) -> bool {
|
||||
|
|
|
|||
|
|
@ -705,3 +705,62 @@ fn pen_click_snaps_to_a_guide() {
|
|||
let x = p["anchors"][0]["p"][0].as_f64().unwrap();
|
||||
assert!((x - 100.0).abs() < 1e-6, "anchor x {x}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pen_continues_an_open_path_from_either_end_point() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let lid = d.default_layer();
|
||||
let id = ItemId(d.alloc());
|
||||
let anchors = vec![
|
||||
designcraft_geom::Anchor::corner(Point::new(100.0, 100.0)),
|
||||
designcraft_geom::Anchor {
|
||||
p: Point::new(200.0, 100.0),
|
||||
h_in: Point::new(170.0, 80.0),
|
||||
h_out: Point::new(230.0, 120.0),
|
||||
kind: designcraft_geom::AnchorKind::Smooth,
|
||||
},
|
||||
];
|
||||
let mut it = Item::new(id, lid, Shape::Path, designcraft_geom::PathData::single(designcraft_geom::SubPath::new(anchors, false)));
|
||||
it.stroke = Stroke::default();
|
||||
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
|
||||
let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false));
|
||||
let cx = ctx(&d, &s, &c, &l);
|
||||
let off = l.xf(SpreadRef::Doc(0)).translation();
|
||||
let at = |x: f64, y: f64| (x + off.x, y + off.y);
|
||||
let ev = |k: PointerKind, (x, y): (f64, f64)| PointerEvent::new(k, x, y);
|
||||
let mut t = create("pen");
|
||||
// The cursor says the end point continues the path; elsewhere it's the plain pen.
|
||||
assert_eq!(t.cursor(&cx, Point::new(at(200.0, 100.0).0, at(200.0, 100.0).1), Mods::default()), Cursor::PenContinue);
|
||||
assert_eq!(t.cursor(&cx, Point::new(at(150.0, 300.0).0, at(150.0, 300.0).1), Mods::default()), Cursor::Pen);
|
||||
// Clicking the last anchor adds nothing yet; the next click appends to that path.
|
||||
assert!(t.pointer(&cx, &ev(PointerKind::Down, at(200.0, 100.0))).is_empty());
|
||||
assert!(t.pointer(&cx, &ev(PointerKind::Up, at(200.0, 100.0))).is_empty(), "a click on the end point changes nothing");
|
||||
t.pointer(&cx, &ev(PointerKind::Down, at(300.0, 200.0)));
|
||||
let a = t.pointer(&cx, &ev(PointerKind::Up, at(300.0, 200.0)));
|
||||
let [Action::Exec(cmd, p)] = a.as_slice() else { panic!("{a:?}") };
|
||||
assert_eq!((cmd.as_str(), p["id"].as_u64(), p["subpath"].as_u64()), ("path.appendAnchor", Some(id.0), Some(0)));
|
||||
assert_eq!((p["anchor"]["p"][0].as_f64(), p["anchor"]["p"][1].as_f64()), (Some(300.0), Some(200.0)));
|
||||
// Clicking the first anchor of the other end closes it.
|
||||
let a = t.pointer(&cx, &ev(PointerKind::Down, at(100.0, 100.0)));
|
||||
assert_eq!(a, vec![Action::Exec("path.close".into(), serde_json::json!({"id": id.0, "subpath": 0}))]);
|
||||
assert!(t.pointer(&cx, &ev(PointerKind::Up, at(100.0, 100.0))).is_empty());
|
||||
// From the first anchor, the path is reversed first; a drag there sets its direction lines.
|
||||
let mut t = create("pen");
|
||||
let a = t.pointer(&cx, &ev(PointerKind::Down, at(100.0, 100.0)));
|
||||
assert_eq!(a, vec![Action::Exec("path.reverse".into(), serde_json::json!({"ids": [id.0], "subpath": 0}))]);
|
||||
t.pointer(&cx, &ev(PointerKind::Drag, at(80.0, 140.0)));
|
||||
let a = t.pointer(&cx, &ev(PointerKind::Up, at(80.0, 140.0)));
|
||||
assert_eq!(
|
||||
a,
|
||||
vec![Action::Exec(
|
||||
"path.setAnchor".into(),
|
||||
serde_json::json!({"id": id.0, "subpath": 0, "anchor": "last", "in": [120.0, 60.0], "out": [80.0, 140.0]})
|
||||
)]
|
||||
);
|
||||
// Closed paths and locked paths aren't continued.
|
||||
let mut d2 = d.clone();
|
||||
d2.item_mut(id).unwrap().locked = true;
|
||||
let cx = ctx(&d2, &s, &c, &l);
|
||||
let t = create("pen");
|
||||
assert_eq!(t.cursor(&cx, Point::new(at(200.0, 100.0).0, at(200.0, 100.0).1), Mods::default()), Cursor::Pen);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -352,7 +352,7 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon {
|
|||
Cursor::ResizeNwSe => C::ResizeNwSe,
|
||||
Cursor::ResizeNeSw => C::ResizeNeSw,
|
||||
Cursor::Rotate => C::Alias,
|
||||
Cursor::Pen => C::Crosshair,
|
||||
Cursor::Pen | Cursor::PenContinue => C::Crosshair,
|
||||
Cursor::Text => C::Text,
|
||||
Cursor::Hand => C::Grab,
|
||||
Cursor::HandGrab => C::Grabbing,
|
||||
|
|
|
|||
|
|
@ -55,6 +55,8 @@ const TABLE: &[(&str, [&str; 5])] = &[
|
|||
("Cut", ["Ausschneiden", "Couper", "Cortar", "カット", "剪切"]),
|
||||
("Copy", ["Kopieren", "Copier", "Copiar", "コピー", "复制"]),
|
||||
("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]),
|
||||
("Reverse Path", ["Pfadrichtung umkehren", "Inverser le tracé", "Invertir trazado", "パスの方向反転", "反转路径"]),
|
||||
("Set Anchor", ["Ankerpunkt festlegen", "Définir le point d’ancrage", "Definir punto de ancla", "アンカーポイントを設定", "设置锚点"]),
|
||||
("Clear", ["Löschen", "Effacer", "Borrar", "消去", "清除"]),
|
||||
("Duplicate", ["Duplizieren", "Dupliquer", "Duplicar", "複製", "重复"]),
|
||||
("Select All", ["Alles auswählen", "Tout sélectionner", "Seleccionar todo", "すべてを選択", "全选"]),
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
|||
("Cut", "قص"),
|
||||
("Copy", "نسخ"),
|
||||
("Paste", "لصق"),
|
||||
("Reverse Path", "عكس المسار"),
|
||||
("Set Anchor", "تعيين نقطة الارتساء"),
|
||||
("Clear", "مسح"),
|
||||
("Delete", "حذف"),
|
||||
("Duplicate", "تكرار"),
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
|||
("Cut", "Taglia"),
|
||||
("Copy", "Copia"),
|
||||
("Paste", "Incolla"),
|
||||
("Reverse Path", "Inverti tracciato"),
|
||||
("Set Anchor", "Imposta ancoraggio"),
|
||||
("Clear", "Cancella"),
|
||||
("Duplicate", "Duplica"),
|
||||
("Select All", "Seleziona tutto"),
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
|||
("Cut", "カット"),
|
||||
("Copy", "コピー"),
|
||||
("Paste", "ペースト"),
|
||||
("Reverse Path", "パスの方向反転"),
|
||||
("Set Anchor", "アンカーポイントを設定"),
|
||||
("Clear", "消去"),
|
||||
("Duplicate", "複製"),
|
||||
("Select All", "すべてを選択"),
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
|||
("Cut", "Recortar"),
|
||||
("Copy", "Copiar"),
|
||||
("Paste", "Colar"),
|
||||
("Reverse Path", "Inverter demarcador"),
|
||||
("Set Anchor", "Definir ponto de ancoragem"),
|
||||
("Clear", "Limpar"),
|
||||
("Delete", "Excluir"),
|
||||
("Duplicate", "Duplicar"),
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
|||
("Cut", "Вирізати"),
|
||||
("Copy", "Копіювати"),
|
||||
("Paste", "Вставити"),
|
||||
("Reverse Path", "Розвернути контур"),
|
||||
("Set Anchor", "Задати прив’язку"),
|
||||
("Clear", "Очистити"),
|
||||
("Duplicate", "Дублювати"),
|
||||
("Select All", "Виділити все"),
|
||||
|
|
|
|||
|
|
@ -486,6 +486,8 @@ pub const MENUS: &[(&str, &[&str])] = &[
|
|||
">Paths",
|
||||
"cmd:object.makeCompoundPath",
|
||||
"cmd:object.releaseCompoundPath",
|
||||
"-",
|
||||
"cmd:path.reverse",
|
||||
"<",
|
||||
">Pathfinder",
|
||||
"cmd:object.pathfinder|Add|{\"op\": \"add\"}",
|
||||
|
|
|
|||
Loading…
Reference in New Issue