mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-26 11:16:50 -06:00
Merge pr/630: Tools: the Pen continues an open path from either end point (#630)
# Conflicts: # ROADMAP.md # crates/engine/src/cmd/object.rs # crates/tools/src/lib.rs # crates/tools/src/pen.rs # crates/tools/src/tests.rs # crates/ui-egui/src/canvas.rs # crates/ui-egui/src/i18n.rs # crates/ui-egui/src/i18n/ar.rs # crates/ui-egui/src/i18n/it.rs # crates/ui-egui/src/i18n/ja.rs # crates/ui-egui/src/i18n/pt_br.rs # crates/ui-egui/src/i18n/uk.rs
This commit is contained in:
@@ -128,6 +128,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** · The Eyedropper puts a colour sampled from an image on the stroke when the toolbox's Stroke box is in front, as in InDesign (a line takes it on its stroke). 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.
|
||||
|
||||
@@ -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",
|
||||
@@ -2687,11 +2713,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;
|
||||
@@ -2700,12 +2735,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;
|
||||
@@ -4213,3 +4339,61 @@ mod spread_target_tests {
|
||||
assert!(s.doc().unwrap().doc.spreads[0].items.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,6 +369,8 @@ pub enum Cursor {
|
||||
PenClose,
|
||||
/// Convert Direction Point.
|
||||
PenConvert,
|
||||
/// The Pen over an open path's end point: a click continues the path.
|
||||
PenContinue,
|
||||
Text,
|
||||
Hand,
|
||||
HandGrab,
|
||||
|
||||
+100
-3
@@ -1,7 +1,8 @@
|
||||
//! 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. Between paths, a click on a selected path's anchor deletes it and a click on its
|
||||
//! outline adds an anchor; the cursor shows which.
|
||||
//! outline adds an anchor; clicking an end point of an open path continues that path from there
|
||||
//! (dragging from it first reshapes its direction lines). The cursor shows which.
|
||||
|
||||
use designcraft_doc::{ItemId, SpreadRef};
|
||||
use designcraft_geom::{BezPath, Point};
|
||||
@@ -22,6 +23,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 {
|
||||
@@ -38,6 +78,8 @@ enum Hover {
|
||||
Delete(u64, usize, usize),
|
||||
/// Add an anchor on a selected path's outline.
|
||||
Add(ItemId),
|
||||
/// Continue subpath `.1` of open path `.0` from its last (`.2`) or first anchor.
|
||||
Continue(ItemId, usize, bool),
|
||||
}
|
||||
|
||||
impl PenTool {
|
||||
@@ -55,6 +97,9 @@ impl PenTool {
|
||||
if !self.anchors.is_empty() {
|
||||
return Hover::Place;
|
||||
}
|
||||
if let Some((id, si, at_end)) = endpoint_at(cx, p) {
|
||||
return Hover::Continue(id, si, at_end);
|
||||
}
|
||||
let ids = &cx.selection.items;
|
||||
if let Some((id, si, ai, None)) = anchor_at_in(cx, ids, p) {
|
||||
return Hover::Delete(id, si, ai);
|
||||
@@ -68,9 +113,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() {
|
||||
@@ -89,7 +160,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![],
|
||||
}
|
||||
}
|
||||
@@ -126,12 +203,19 @@ impl Tool for PenTool {
|
||||
match self.hover_at(cx, ev.pos) {
|
||||
Hover::Close => {
|
||||
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;
|
||||
}
|
||||
Hover::Continue(id, si, at_end) => return self.continue_from(cx, id, si, at_end),
|
||||
Hover::Delete(id, si, ai) => {
|
||||
return vec![Action::Exec("path.deleteAnchor".into(), json!({"id": id, "subpath": si, "anchor": ai}))];
|
||||
}
|
||||
@@ -149,6 +233,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;
|
||||
@@ -161,6 +246,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![],
|
||||
@@ -210,6 +306,7 @@ impl Tool for PenTool {
|
||||
match self.hover_at(cx, p) {
|
||||
Hover::Place => Cursor::Pen,
|
||||
Hover::Close => Cursor::PenClose,
|
||||
Hover::Continue(..) => Cursor::PenContinue,
|
||||
Hover::Delete(..) => Cursor::PenDelete,
|
||||
Hover::Add(_) => Cursor::PenAdd,
|
||||
}
|
||||
|
||||
@@ -1520,3 +1520,62 @@ fn fitting_handle_axes_follow_spread_and_frame_rotation() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
@@ -96,6 +96,7 @@ enum Badge {
|
||||
Delete,
|
||||
Close,
|
||||
Convert,
|
||||
Continue,
|
||||
}
|
||||
|
||||
/// The pen nib, tip at `o` (top left), with a badge below right.
|
||||
@@ -121,6 +122,7 @@ fn pen(ink: &mut Ink, o: Pos2, badge: Badge) {
|
||||
ink.line(b + vec2(0.0, 6.0), b + vec2(3.0, 0.0));
|
||||
ink.line(b + vec2(3.0, 0.0), b + vec2(6.0, 6.0));
|
||||
}
|
||||
Badge::Continue => ink.line(b + vec2(1.0, 6.0), b + vec2(5.0, 0.0)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,6 +154,7 @@ fn glyph(c: Cursor, p: Pos2) -> Option<Vec<Shape>> {
|
||||
Cursor::PenDelete => pen(&mut ink, p, Badge::Delete),
|
||||
Cursor::PenClose => pen(&mut ink, p, Badge::Close),
|
||||
Cursor::PenConvert => pen(&mut ink, p, Badge::Convert),
|
||||
Cursor::PenContinue => pen(&mut ink, p, Badge::Continue),
|
||||
Cursor::Rotate => rotate(&mut ink, p),
|
||||
_ => return None,
|
||||
}
|
||||
@@ -270,7 +273,7 @@ pub fn system(c: Cursor) -> egui::CursorIcon {
|
||||
Cursor::ResizeNwSe => C::ResizeNwSe,
|
||||
Cursor::ResizeNeSw => C::ResizeNeSw,
|
||||
Cursor::Rotate => C::Alias,
|
||||
Cursor::Pen | Cursor::PenAdd | Cursor::PenDelete | Cursor::PenClose | Cursor::PenConvert => C::Crosshair,
|
||||
Cursor::Pen | Cursor::PenAdd | Cursor::PenDelete | Cursor::PenClose | Cursor::PenConvert | Cursor::PenContinue => C::Crosshair,
|
||||
Cursor::Text => C::Text,
|
||||
Cursor::Hand => C::Grab,
|
||||
Cursor::HandGrab => C::Grabbing,
|
||||
@@ -320,8 +323,17 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Every cursor with a glyph.
|
||||
const GLYPHS: [Cursor; 8] =
|
||||
[Cursor::Arrow, Cursor::ArrowHollow, Cursor::Pen, Cursor::PenAdd, Cursor::PenDelete, Cursor::PenClose, Cursor::PenConvert, Cursor::Rotate];
|
||||
const GLYPHS: [Cursor; 9] = [
|
||||
Cursor::Arrow,
|
||||
Cursor::ArrowHollow,
|
||||
Cursor::Pen,
|
||||
Cursor::PenAdd,
|
||||
Cursor::PenDelete,
|
||||
Cursor::PenClose,
|
||||
Cursor::PenConvert,
|
||||
Cursor::PenContinue,
|
||||
Cursor::Rotate,
|
||||
];
|
||||
|
||||
fn alpha(img: &CustomCursorImage, x: usize, y: usize) -> u8 {
|
||||
img.rgba.get((y * img.size[0] as usize + x) * 4 + 3).copied().unwrap_or(0)
|
||||
|
||||
@@ -65,6 +65,8 @@ const TABLE: &[(&str, [&str; 5])] = &[
|
||||
"Paste Vector Art",
|
||||
["Vektorgrafik einfügen", "Coller l’illustration vectorielle", "Pegar ilustración vectorial", "ベクトルアートをペースト", "粘贴矢量图稿"],
|
||||
),
|
||||
("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", "すべてを選択", "全选"]),
|
||||
|
||||
@@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Copy", "نسخ"),
|
||||
("Paste", "لصق"),
|
||||
("Paste Vector Art", "لصق رسم متجه"),
|
||||
("Reverse Path", "عكس المسار"),
|
||||
("Set Anchor", "تعيين نقطة الارتساء"),
|
||||
("Clear", "مسح"),
|
||||
("Delete", "حذف"),
|
||||
("Duplicate", "تكرار"),
|
||||
|
||||
@@ -29,6 +29,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Copy", "Copia"),
|
||||
("Paste", "Incolla"),
|
||||
("Paste Vector Art", "Incolla grafica vettoriale"),
|
||||
("Reverse Path", "Inverti tracciato"),
|
||||
("Set Anchor", "Imposta ancoraggio"),
|
||||
("Clear", "Cancella"),
|
||||
("Duplicate", "Duplica"),
|
||||
("Select All", "Seleziona tutto"),
|
||||
|
||||
@@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Copy", "コピー"),
|
||||
("Paste", "ペースト"),
|
||||
("Paste Vector Art", "ベクトルアートをペースト"),
|
||||
("Reverse Path", "パスの方向反転"),
|
||||
("Set Anchor", "アンカーポイントを設定"),
|
||||
("Clear", "消去"),
|
||||
("Duplicate", "複製"),
|
||||
("Select All", "すべてを選択"),
|
||||
|
||||
@@ -32,6 +32,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Copy", "Copiar"),
|
||||
("Paste", "Colar"),
|
||||
("Paste Vector Art", "Colar arte vetorial"),
|
||||
("Reverse Path", "Inverter demarcador"),
|
||||
("Set Anchor", "Definir ponto de ancoragem"),
|
||||
("Clear", "Limpar"),
|
||||
("Delete", "Excluir"),
|
||||
("Duplicate", "Duplicar"),
|
||||
|
||||
@@ -30,6 +30,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Copy", "Копіювати"),
|
||||
("Paste", "Вставити"),
|
||||
("Paste Vector Art", "Вставити векторну графіку"),
|
||||
("Reverse Path", "Розвернути контур"),
|
||||
("Set Anchor", "Задати прив’язку"),
|
||||
("Clear", "Очистити"),
|
||||
("Duplicate", "Дублювати"),
|
||||
("Select All", "Виділити все"),
|
||||
|
||||
@@ -530,6 +530,8 @@ pub const MENUS: &[(&str, &[&str])] = &[
|
||||
">Paths",
|
||||
"cmd:object.makeCompoundPath",
|
||||
"cmd:object.releaseCompoundPath",
|
||||
"-",
|
||||
"cmd:path.reverse",
|
||||
"<",
|
||||
">Pathfinder",
|
||||
"cmd:object.pathfinder|Add|{\"op\": \"add\"}",
|
||||
|
||||
Reference in New Issue
Block a user