mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-15 03:37:07 -04:00
Merge branch 'pr/630' into batch1
This commit is contained in:
commit
8a36af3af8
13 files changed
+368
-12
No files matched your search
@@ -135,6 +135,7 @@ Newest first.
|
||||
- **2026-10-12** · Book numbering and style updates prepare every chapter before saving. Only closed native chapters are changed; imported IDML remains a read-only style/PDF source. If a later disk write fails, the error names the completed chapters; the batch is not a whole-book transaction. No readiness percentage change.
|
||||
- **2026-10-12** · Desktop write callbacks and CLI raster exports use the atomic writer too; open readers retain the original bytes, while private Unix modes and symlink targets survive replacement. Permanent tests cover both actual callers. No readiness percentage change.
|
||||
- **2026-10-12** · Save As, library edits and book collection edits persist prospective values before changing live state; failed writes preserve the document and existing collections. Save a Copy rebases data-source paths for its destination without changing the open document. No readiness percentage change.
|
||||
- **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",
|
||||
@@ -1975,11 +2001,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;
|
||||
@@ -1988,12 +2023,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;
|
||||
@@ -2995,3 +3121,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,
|
||||
|
||||
+102
-6
@@ -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\"}",
|
||||
|
||||
Reference in new issue
Block a user