Direct Selection tool: reshape pen and shape paths after they're drawn (#790) (#865)

* Direct Selection tool: reshape paths after they're drawn (#790)

Adds Photoshop's Direct Selection tool (A, in the Path Selection flyout) and
the Pen's Cmd/Ctrl (Direct Selection) and Alt (Convert Point) modifiers. It
selects and drags anchors, direction handles and segments of the work path,
the active shape layer's path and a targeted vector mask.

The geometry lives in photocraft_vector::edit; new engine commands
path.moveAnchors, path.moveHandle, path.bendSegment and path.convertPoint
apply it (one history step per drag, drivable over MCP and the control
channel), and the canvas previews a drag with the same code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tool::ALL holds 49 tools now that Direct Selection and Magnetic Lasso both landed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
BFlat 2026-10-08 09:58:31 +07:00 committed by GitHub
parent ec7302f4b3
commit 75ff41a26b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 1255 additions and 70 deletions

View File

@ -16,10 +16,11 @@ next to them.
| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `eraser-background.svg`, `eraser-magic.svg` and `lasso-magnetic.svg` (109 files) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg` and `direct-select.svg` (109 files) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/{check,chevron-down,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,lock,minus,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/eraser-background.svg`, `assets/icons/eraser-magic.svg` | Background Eraser and Magic Eraser tool glyphs: Lucide `eraser` plus added marks | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/eraser.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/direct-select.svg` | Direct Selection tool glyph: Lucide `mouse-pointer-2` plus an anchor square | Lucide Icons and Contributors; square by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/mouse-pointer-2.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/lasso-magnetic.svg` | Magnetic Lasso tool glyph, adapted from "Magnetic Lasso Tool" (redrawn on the 24 px toolbar grid) | Lil' Seal (Noun Project); adapted by PhotoCraft contributors | <https://thenounproject.com/icon/magnetic-lasso-tool-177513/> | CC BY 3.0, [`assets/icons/LICENSE-noun-magnetic-lasso.txt`](assets/icons/LICENSE-noun-magnetic-lasso.txt) |
| `assets/dict/en_US-scowl-50.txt.gz` | English word list for Check Spelling (SCOWL 2020.12.07, size 50, en_US; 72,403 words) | Kevin Atkinson and the SCOWL contributors | <http://wordlist.aspell.net/> | SCOWL license (permissive, keep the notice), [`assets/dict/LICENSE-SCOWL.txt`](assets/dict/LICENSE-SCOWL.txt), which also has the build recipe |
| `crates/cms/profiles/photocraft-coated-cmyk.icc` | PhotoCraft Coated CMYK (synthetic) ICC profile | PhotoCraft contributors (generated by `photocraft-cms`, `src/synth.rs`) | Original work, see [`crates/cms/README.md`](crates/cms/README.md) | CC0-1.0 |

View File

@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4.037 4.688a.495.495 0 0 1 .651-.651l16 6.5a.5.5 0 0 1-.063.947l-6.124 1.58a2 2 0 0 0-1.438 1.435l-1.579 6.126a.5.5 0 0 1-.947.063z" />
<rect x="16.5" y="16.5" width="4.5" height="4.5" />
</svg>

After

Width:  |  Height:  |  Size: 410 B

View File

@ -981,6 +981,7 @@ fn build() -> Vec<CommandSpec> {
v.extend(crate::transform_cmds::specs());
v.extend(crate::float_cmds::specs());
v.extend(crate::vector_cmds::specs());
v.extend(crate::path_edit_cmds::specs());
v.extend(crate::smartselect_cmds::specs());
v.extend(crate::cutout_cmds::specs());
v.extend(crate::symmetry_cmds::specs());

View File

@ -56,6 +56,7 @@ pub mod mode_cmds;
pub mod multichannel_cmds;
pub mod notes_cmds;
pub mod paint_cmds;
mod path_edit_cmds;
pub mod pattern_cmds;
pub mod photo_cmds;
pub mod pick_cmds;

View File

@ -0,0 +1,262 @@
//! Point-level path edits, what Photoshop's Direct Selection (A) and Convert Point tools do on
//! the canvas: move anchors, drag direction handles, bend segments and convert points. The
//! geometry is `photocraft_vector::edit`; the canvas previews a drag with it and commits the
//! whole drag as one command (one history step).
//!
//! Every command targets a path like `path.transform`: `"name":"work"` (default), a saved path's
//! name, or `"layer"` with `"layer":id?` for a shape layer's path (re-rendered; it stops being a
//! live shape) or a layer's vector mask. Knots are addressed by `subpath` and `knot` index, as
//! listed by `path.info`, and the result is `{name, path}` in the compact JSON form.
use photocraft_doc::{LayerContent, Path};
use photocraft_geom::Point;
use photocraft_vector::edit::{self, Handle};
use serde_json::{Value, json};
use crate::commands::CommandSpec;
use crate::vector_cmds::{bad, has_doc, layer_id, nums, path_json, path_mut, pt, with_shape};
use crate::{Result, Session};
/// Coordinates and offsets beyond this are mistakes (the same bound as `path.transform`).
const MAX: f64 = 1_000_000.0;
fn finite(v: [f64; 2]) -> bool {
v.iter().all(|c| c.is_finite() && c.abs() <= MAX)
}
fn offset(cmd: &str, p: &Value) -> Result<[f64; 2]> {
nums::<2>(p, "move").filter(|d| finite(*d)).ok_or_else(|| bad(cmd, "`move` must be [dx, dy] with finite numbers of magnitude at most 1000000"))
}
fn point(cmd: &str, p: &Value, key: &str) -> Result<Option<Point>> {
p.get(key)
.map(|v| {
pt(v).filter(|q| finite([q.x, q.y])).ok_or_else(|| bad(cmd, format!("`{key}` must be [x, y] with finite numbers of magnitude at most 1000000")))
})
.transpose()
}
fn index(cmd: &str, p: &Value, key: &str) -> Result<usize> {
p.get(key).and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(cmd, format!("missing `{key}` index")))
}
fn knot(cmd: &str, p: &Value) -> Result<[usize; 2]> {
Ok([index(cmd, p, "subpath")?, index(cmd, p, "knot")?])
}
/// Applies `f` to the targeted path in one undoable step labelled `label`.
fn edit_path(s: &mut Session, cmd: &str, p: &Value, label: &str, f: impl FnOnce(&mut Path) -> std::result::Result<(), String>) -> Result<Value> {
let name = p.get("name").and_then(Value::as_str).unwrap_or("work").to_owned();
let layer = if name == "layer" { Some(layer_id(s, p)?) } else { None };
let shape = layer.filter(|id| s.active().and_then(|d| d.doc.layer(*id)).is_some_and(|l| matches!(l.content, LayerContent::Shape(_))));
let path = match shape {
Some(id) => with_shape(s, id, label, |sh, _| {
f(&mut sh.path).map_err(|e| bad(cmd, e))?;
sh.live = None;
sh.psd_raw = None;
Ok(path_json(&sh.path))
})?,
None => s.edit(label, |doc, _| {
let path = path_mut(doc, &name, layer)?;
f(path).map_err(|e| bad(cmd, e))?;
Ok(path_json(path))
})?,
};
Ok(json!({ "name": name, "path": path }))
}
fn move_anchors(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "path.moveAnchors";
let anchors: Vec<[usize; 2]> = p
.get("anchors")
.and_then(Value::as_array)
.filter(|a| !a.is_empty())
.ok_or_else(|| bad(CMD, "`anchors` must list [subpath, knot] pairs"))?
.iter()
.map(|a| {
let n = |i: usize| a.get(i).and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok());
n(0).zip(n(1)).map(|(s, k)| [s, k]).ok_or_else(|| bad(CMD, "each anchor is [subpath, knot]"))
})
.collect::<Result<_>>()?;
let d = offset(CMD, p)?;
edit_path(s, CMD, p, "Drag Anchor Points", |path| edit::move_anchors(path, &anchors, d))
}
fn move_handle(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "path.moveHandle";
let k = knot(CMD, p)?;
let h = p.get("handle").and_then(Value::as_str).and_then(Handle::parse).ok_or_else(|| bad(CMD, "`handle` must be \"in\" or \"out\""))?;
let to = point(CMD, p, "to")?.ok_or_else(|| bad(CMD, "missing `to`"))?;
let independent = p.get("independent").and_then(Value::as_bool).unwrap_or(false);
edit_path(s, CMD, p, "Drag Direction Point", |path| edit::move_handle(path, k, h, to, independent))
}
fn bend_segment(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "path.bendSegment";
let k = knot(CMD, p)?;
let t = match p.get("t") {
Some(v) => v.as_f64().filter(|t| (0.0..=1.0).contains(t)).ok_or_else(|| bad(CMD, "`t` must be between 0 and 1"))?,
None => 0.5,
};
let d = offset(CMD, p)?;
edit_path(s, CMD, p, "Drag Segment", |path| edit::bend_segment(path, k, t, d))
}
fn convert_point(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "path.convertPoint";
let k = knot(CMD, p)?;
let out = point(CMD, p, "out")?;
edit_path(s, CMD, p, "Convert Point", |path| edit::convert_point(path, k, out))
}
pub fn specs() -> Vec<CommandSpec> {
const TARGET: &str = r#""name":str|"work"|"layer"="work","layer":id? (with "layer": a shape layer's path or a vector mask)"#;
let spec = |id: &'static str, label: &'static str, params: String, run: fn(&mut Session, &Value) -> Result<Value>| CommandSpec {
id,
label,
menu: &[],
shortcut: None,
params: crate::vector_cmds::leak(format!("{{{TARGET},{params}}} → {{name,path}}")),
enabled: has_doc,
run,
journal: true,
};
vec![
spec("path.moveAnchors", "Drag Anchor Points", r#""anchors":[[subpath,knot],…],"move":[dx,dy] (anchors move with their handles; Direct Selection drag)"#.into(), move_anchors),
spec(
"path.moveHandle",
"Drag Direction Point",
r#""subpath":i,"knot":j,"handle":"in|out","to":[x,y],"independent":bool=false (a smooth point turns its other handle to stay collinear; independent = ⌥ / Convert Point, makes a corner)"#.into(),
move_handle,
),
spec(
"path.bendSegment",
"Drag Segment",
r#""subpath":i,"knot":j (the segment leaving it),"t":0..1=0.5 (where it was grabbed),"move":[dx,dy] (a straight segment moves with its anchors; a curve reshapes through the dragged point)"#.into(),
bend_segment,
),
spec(
"path.convertPoint",
"Convert Point",
r#""subpath":i,"knot":j,"out":[x,y]? (none: corner with retracted handles; given: smooth with this out handle and its mirror)"#.into(),
convert_point,
),
]
}
#[cfg(test)]
mod tests {
use photocraft_doc::{LayerId, ShapeLayer};
use serde_json::json;
use crate::Session;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": 120, "height": 100, "background": "white"})).unwrap();
s
}
fn square() -> serde_json::Value {
json!({"subpaths": [{"closed": true, "knots": [[10, 10], [60, 10], [60, 60], [10, 60]]}]})
}
fn anchor(v: &serde_json::Value, k: usize) -> [f64; 2] {
serde_json::from_value(v["path"]["subpaths"][0]["knots"][k]["anchor"].clone()).unwrap()
}
fn work(s: &Session) -> photocraft_doc::Path {
s.active().unwrap().doc.work_path.clone().unwrap()
}
#[test]
fn anchors_move_in_one_undoable_step() {
let mut s = session();
s.execute("path.set", json!({"path": square()})).unwrap();
let before = work(&s);
let r = s.execute("path.moveAnchors", json!({"anchors": [[0, 1], [0, 2]], "move": [5, -3]})).unwrap();
assert_eq!((anchor(&r, 1), anchor(&r, 2), anchor(&r, 0)), ([65.0, 7.0], [65.0, 57.0], [10.0, 10.0]));
assert_eq!(s.active().unwrap().history.undo_label(), Some("Drag Anchor Points"));
assert!(s.undo());
assert_eq!(work(&s), before);
}
#[test]
fn a_coalesced_drag_is_one_history_step() {
let mut s = session();
s.execute("path.set", json!({"path": square()})).unwrap();
let steps = s.active().unwrap().history.entries().len();
for _ in 0..3 {
s.execute("path.moveAnchors", json!({"anchors": [[0, 0]], "move": [1, 0], "coalesce": "drag-1"})).unwrap();
}
assert_eq!(s.active().unwrap().history.entries().len(), steps + 1);
assert_eq!(work(&s).subpaths[0].knots[0].anchor.x, 13.0);
}
#[test]
fn handles_segments_and_convert_point() {
let mut s = session();
s.execute("path.set", json!({"path": square()})).unwrap();
// Pull smooth handles out of the corner at (60, 10), then turn one: the other follows.
s.execute("path.convertPoint", json!({"subpath": 0, "knot": 1, "out": [70, 10]})).unwrap();
let r = s.execute("path.moveHandle", json!({"subpath": 0, "knot": 1, "handle": "out", "to": [60, 20]})).unwrap();
let k = &r["path"]["subpaths"][0]["knots"][1];
assert_eq!((k["in"].clone(), k["smooth"].clone()), (json!([60.0, 0.0]), json!(true)));
let r = s.execute("path.moveHandle", json!({"subpath": 0, "knot": 1, "handle": "in", "to": [50, 0], "independent": true})).unwrap();
let k = &r["path"]["subpaths"][0]["knots"][1];
assert_eq!((k["out"].clone(), k["smooth"].clone()), (json!([60.0, 20.0]), json!(false)));
// The straight bottom edge (2 → 3) drags with its anchors.
let r = s.execute("path.bendSegment", json!({"subpath": 0, "knot": 2, "move": [0, 8]})).unwrap();
assert_eq!((anchor(&r, 2), anchor(&r, 3)), ([60.0, 68.0], [10.0, 68.0]));
let r = s.execute("path.convertPoint", json!({"subpath": 0, "knot": 1})).unwrap();
assert_eq!(r["path"]["subpaths"][0]["knots"][1]["out"], json!([60.0, 10.0]));
}
#[test]
fn shape_paths_and_vector_masks_are_edited_and_rerendered() {
for depth in [8, 16, 32] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 120, "height": 100, "depth": depth})).unwrap();
let id = s.execute("shape.create", json!({"kind": "rect", "rect": [10, 10, 40, 40], "fill": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
let r = s.execute("path.moveAnchors", json!({"name": "layer", "layer": id, "anchors": [[0, 1], [0, 2]], "move": [30, 0]})).unwrap();
assert_eq!(anchor(&r, 1), [80.0, 10.0]);
let st = s.active().unwrap();
let photocraft_doc::LayerContent::Shape(ShapeLayer { live, cache, .. }) = &st.doc.layer(LayerId(id)).unwrap().content else { panic!("shape") };
assert!(live.is_none(), "an edited rectangle is a plain path now");
assert_eq!(cache.as_ref().unwrap().content_bounds().x1, 80, "depth {depth}: re-rendered");
}
let mut s = session();
let id = s.active().unwrap().active_layer.unwrap().0;
s.execute("layer.vectorMask.add", json!({"layer": id, "path": square()})).unwrap();
s.execute("path.moveAnchors", json!({"name": "layer", "layer": id, "anchors": [[0, 0]], "move": [-5, -5]})).unwrap();
let vm = s.active().unwrap().doc.layer(LayerId(id)).unwrap().vector_mask.clone().unwrap();
assert_eq!(vm.path.subpaths[0].knots[0].anchor, photocraft_geom::Point::new(5.0, 5.0));
}
#[test]
fn bad_params_fail_without_an_edit() {
let mut s = session();
assert!(s.execute("path.moveAnchors", json!({"anchors": [[0, 0]], "move": [1, 1]})).is_err(), "no work path");
s.execute("path.set", json!({"path": square()})).unwrap();
let steps = s.active().unwrap().history.entries().len();
for (id, p) in [
("path.moveAnchors", json!({"anchors": [[0, 9]], "move": [1, 1]})),
("path.moveAnchors", json!({"anchors": [], "move": [1, 1]})),
("path.moveAnchors", json!({"anchors": [[0]], "move": [1, 1]})),
("path.moveAnchors", json!({"anchors": [[0, 0]], "move": [1e300, 1]})),
("path.moveAnchors", json!({"anchors": [[0, 0]]})),
("path.moveHandle", json!({"subpath": 0, "knot": 0, "handle": "up", "to": [1, 1]})),
("path.moveHandle", json!({"subpath": 0, "knot": 0, "handle": "in"})),
("path.moveHandle", json!({"subpath": -1, "knot": 0, "handle": "in", "to": [1, 1]})),
("path.bendSegment", json!({"subpath": 0, "knot": 0, "t": 2, "move": [1, 1]})),
("path.bendSegment", json!({"subpath": 5, "knot": 0, "move": [1, 1]})),
("path.convertPoint", json!({"subpath": 0, "knot": 4})),
("path.convertPoint", json!({"subpath": 0, "knot": 0, "out": "x"})),
("path.convertPoint", json!({"name": "nope", "subpath": 0, "knot": 0})),
("path.convertPoint", json!({"name": "layer", "subpath": 0, "knot": 0})),
] {
assert!(s.execute(id, p.clone()).is_err(), "{id} {p}");
}
assert_eq!(s.active().unwrap().history.entries().len(), steps);
}
}

View File

@ -32,11 +32,11 @@ use crate::{EngineError, Result, Session};
/// Keys PSD uses for a shape layer's vector data; dropped when a shape is rasterized.
const SHAPE_BLOCKS: [&[u8; 4]; 8] = [b"vmsk", b"vsms", b"vogk", b"vstk", b"vscg", b"SoCo", b"GdFl", b"PtFl"];
fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
pub(crate) fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
}
fn has_doc(s: &Session) -> std::result::Result<(), String> {
pub(crate) fn has_doc(s: &Session) -> std::result::Result<(), String> {
s.active().map(|_| ()).ok_or_else(|| "no document open".into())
}
@ -109,7 +109,7 @@ fn paste_shape_stroke(s: &mut Session, p: &Value) -> Result<Value> {
shape_info(s, id)
}
fn layer_id(s: &Session, p: &Value) -> Result<LayerId> {
pub(crate) fn layer_id(s: &Session, p: &Value) -> Result<LayerId> {
match p.get("layer").and_then(Value::as_u64) {
Some(id) => Ok(LayerId(id)),
None => s.active().and_then(|d| d.active_layer).ok_or(EngineError::Other("no active layer".into())),
@ -120,7 +120,7 @@ fn f64p(p: &Value, k: &str) -> Option<f64> {
p.get(k).and_then(Value::as_f64)
}
fn pt(v: &Value) -> Option<Point> {
pub(crate) fn pt(v: &Value) -> Option<Point> {
match v {
Value::Array(a) if a.len() >= 2 => Some(Point::new(a[0].as_f64()?, a[1].as_f64()?)),
Value::Object(_) => Some(Point::new(v.get("x")?.as_f64()?, v.get("y")?.as_f64()?)),
@ -128,7 +128,7 @@ fn pt(v: &Value) -> Option<Point> {
}
}
fn nums<const N: usize>(p: &Value, k: &str) -> Option<[f64; N]> {
pub(crate) fn nums<const N: usize>(p: &Value, k: &str) -> Option<[f64; N]> {
let a = p.get(k)?.as_array()?;
if a.len() < N {
return None;
@ -518,7 +518,7 @@ fn shape_info(s: &Session, id: LayerId) -> Result<Value> {
}))
}
fn with_shape<R>(s: &mut Session, id: LayerId, label: &str, f: impl FnOnce(&mut ShapeLayer, &mut Layer) -> Result<R>) -> Result<R> {
pub(crate) fn with_shape<R>(s: &mut Session, id: LayerId, label: &str, f: impl FnOnce(&mut ShapeLayer, &mut Layer) -> Result<R>) -> Result<R> {
s.edit(label, |doc, _| {
let snapshot = doc.clone();
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
@ -700,6 +700,19 @@ fn resolve_path(s: &Session, name: Option<&str>) -> Result<Path> {
d.doc.paths.iter().find(|p| p.name == n).map(|p| p.path.clone()).ok_or_else(|| EngineError::Other(format!("no path named \"{n}\"")))
}
/// The path an edit targets, except a shape layer's (edit those with [`with_shape`], which
/// re-renders them): `layer`'s vector mask when given, else the work path or a saved path.
pub(crate) fn path_mut<'a>(doc: &'a mut Document, name: &str, layer: Option<LayerId>) -> Result<&'a mut Path> {
if let Some(id) = layer {
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
return l.vector_mask.as_mut().map(|vm| &mut vm.path).ok_or_else(|| EngineError::Other("active layer has no shape path or vector mask".into()));
}
if is_work(Some(name)) {
return doc.work_path.as_mut().ok_or_else(|| EngineError::Other("no work path".into()));
}
doc.paths.iter_mut().find(|q| q.name == name).map(|q| &mut q.path).ok_or_else(|| EngineError::Other(format!("no path named \"{name}\"")))
}
fn paths_list(s: &Session) -> Result<Value> {
let d = s.active().ok_or(EngineError::NoDocument)?;
let doc = &d.doc;
@ -850,17 +863,8 @@ fn path_transform(s: &mut Session, p: &Value) -> Result<Value> {
return Ok(json!({ "name": name, "matrix": m }));
}
s.edit("Transform Path", |doc, _| {
if let Some(id) = layer_id {
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
let vm = l.vector_mask.as_mut().ok_or_else(|| EngineError::Other("active layer has no shape path or vector mask".into()))?;
vm.path = vm.path.transform(&transform);
} else if is_work(Some(&name)) {
let path = doc.work_path.as_mut().ok_or_else(|| EngineError::Other("no work path".into()))?;
*path = path.transform(&transform);
} else {
let path = doc.paths.iter_mut().find(|path| path.name == name).ok_or_else(|| EngineError::Other(format!("no path named \"{name}\"")))?;
path.path = path.path.transform(&transform);
}
let path = path_mut(doc, &name, layer_id)?;
*path = path.transform(&transform);
Ok(())
})?;
Ok(json!({ "name": name, "matrix": m }))
@ -1226,7 +1230,7 @@ fn selection_to_shape(s: &mut Session, p: &Value) -> Result<Value> {
// Specs
// ---------------------------------------------------------------------------
const PATH_FORM: &str = r##"path: {"subpaths":[{"closed":bool=true,"op":"combine|subtract|intersect|exclude","knots":[[x,y] | {"anchor":[x,y],"in":[x,y],"out":[x,y],"smooth":bool}]}],"fillRule":"nonzero|evenodd","inverted":bool}"##;
pub(crate) const PATH_FORM: &str = r##"path: {"subpaths":[{"closed":bool=true,"op":"combine|subtract|intersect|exclude","knots":[[x,y] | {"anchor":[x,y],"in":[x,y],"out":[x,y],"smooth":bool}]}],"fillRule":"nonzero|evenodd","inverted":bool}"##;
macro_rules! spec {
($id:literal, $label:literal, [$($m:literal),*], $params:expr, $en:expr, $run:expr) => {
@ -1234,7 +1238,7 @@ macro_rules! spec {
};
}
fn leak(s: String) -> &'static str {
pub(crate) fn leak(s: String) -> &'static str {
Box::leak(s.into_boxed_str())
}

View File

@ -2422,6 +2422,10 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
}
// Move tool: ⇧ locks the axis, ⌥ duplicates (move_mods.rs).
let ev = crate::move_mods::filter_event(app, ev, mods);
// Direct Selection, and the Pen's ⌘ (Direct Selection) and ⌥ (Convert Point) modes (#790).
if crate::direct_select::pointer(app, ev, mods) {
return;
}
// Ruler, Count and Note tools.
if crate::analysis_ui::pointer(app, ev, mods) {
return;

View File

@ -0,0 +1,324 @@
//! Direct Selection tool (A, #790), and the Pen's ⌘/Ctrl (Direct Selection) and ⌥/Alt (Convert
//! Point) modifiers, as in Photoshop. It edits the paths the canvas shows: the targeted vector
//! mask or the active shape layer's path, and the work path.
//!
//! * Click an anchor to select it (⇧ adds or removes, ⌥ selects its whole subpath), drag on empty
//! canvas to marquee-select anchors, click empty canvas to deselect.
//! * Drag selected anchors to move them, a direction handle to turn it (a smooth point keeps its
//! handles collinear; ⌥ breaks them apart), or a segment to bend it (straight segments move).
//! * Pen + ⌥: click an anchor to make it a corner, drag from it to pull out smooth handles, drag a
//! handle to move it alone.
//!
//! A drag previews with `photocraft_vector::edit` and commits once on release through the engine
//! (`path.moveAnchors`, `path.moveHandle`, `path.bendSegment`, `path.convertPoint`): one history
//! step per drag, and the same commands drive it over MCP and the control channel.
use egui::{Color32, Modifiers, Pos2};
use photocraft_doc::vector::Path;
use photocraft_geom::Point;
use photocraft_vector::edit::{self, Handle, Hit};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::PhotocraftApp;
use crate::canvas::ToolEvent;
use crate::state::Tool;
/// A path Direct Selection edits.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PathRef {
Work,
/// A layer's shape path, or its vector mask.
Layer(u64),
}
impl PathRef {
/// The engine's target params for this path.
fn params(self) -> Value {
match self {
PathRef::Work => json!({"name": "work"}),
PathRef::Layer(id) => json!({"name": "layer", "layer": id}),
}
}
}
/// Direct Selection state: which anchors are selected and the drag in progress.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct DirectSelection {
/// The path whose anchors are selected.
pub target: Option<PathRef>,
/// Selected anchors as `[subpath, knot]`.
pub anchors: Vec<[usize; 2]>,
/// A clicked segment (`[subpath, knot]` it leaves): its two handles show.
pub segment: Option<[usize; 2]>,
#[serde(skip)]
pub drag: Option<Drag>,
}
#[derive(Clone, Debug, PartialEq)]
pub enum Drag {
/// Editing `target`: `base` is its path at the press, `preview` the edit so far.
Edit { target: PathRef, base: Path, edit: Edit, start: [f64; 2], end: [f64; 2], preview: Option<Path> },
/// Marquee-selecting anchors (⇧ adds to the selection).
Marquee { start: [f64; 2], end: [f64; 2], add: bool },
}
#[derive(Clone, Debug, PartialEq)]
pub enum Edit {
Anchors(Vec<[usize; 2]>),
/// A handle, where it was at the press, and whether it moves alone (⌥).
Handle([usize; 2], Handle, Point, bool),
Segment([usize; 2], f64),
/// Pen + ⌥ on an anchor: a click makes a corner, a drag pulls out smooth handles.
Convert([usize; 2]),
}
impl Edit {
/// The edit for a drag from `start` to `end`, as a command and its params (without target).
fn command(&self, start: [f64; 2], end: [f64; 2]) -> (&'static str, Value) {
let d = [end[0] - start[0], end[1] - start[1]];
match self {
Edit::Anchors(a) => ("path.moveAnchors", json!({"anchors": a, "move": d})),
Edit::Handle([s, k], h, from, independent) => {
("path.moveHandle", json!({"subpath": s, "knot": k, "handle": h.name(), "to": [from.x + d[0], from.y + d[1]], "independent": independent}))
}
Edit::Segment([s, k], t) => ("path.bendSegment", json!({"subpath": s, "knot": k, "t": t, "move": d})),
Edit::Convert([s, k]) => ("path.convertPoint", json!({"subpath": s, "knot": k, "out": end})),
}
}
/// `base` edited as the command would: the drag preview.
fn preview(&self, base: &Path, start: [f64; 2], end: [f64; 2]) -> Option<Path> {
let d = [end[0] - start[0], end[1] - start[1]];
let mut p = base.clone();
match self {
Edit::Anchors(a) => edit::move_anchors(&mut p, a, d),
Edit::Handle(k, h, from, independent) => edit::move_handle(&mut p, *k, *h, Point::new(from.x + d[0], from.y + d[1]), *independent),
Edit::Segment(k, t) => edit::bend_segment(&mut p, *k, *t, d),
Edit::Convert(k) => edit::convert_point(&mut p, *k, Some(Point::new(end[0], end[1]))),
}
.ok()?;
Some(p)
}
}
/// Is Direct Selection what the pointer does now: its own tool, or the Pen with ⌘/Ctrl held.
fn direct(tool: Tool, mods: Modifiers) -> bool {
tool == Tool::DirectSelection || (tool == Tool::Pen && mods.command)
}
/// Does the canvas draw Direct Selection's overlay instead of the plain path outlines?
pub fn shows(app: &PhotocraftApp, mods: Modifiers) -> bool {
direct(app.ui.tool, mods) || app.ui.direct_selection.drag.is_some()
}
/// The paths Direct Selection edits, front first: the targeted vector mask or the active shape
/// layer's path, then the work path.
fn candidates(app: &PhotocraftApp) -> Vec<(PathRef, Path)> {
let layer = crate::vector_ui::targeted_vector_mask(app).or_else(|| crate::vector_ui::active_shape_path(app));
let work = app.session.active().and_then(|st| st.doc.work_path.clone());
layer.map(|(id, p)| (PathRef::Layer(id), p)).into_iter().chain(work.map(|p| (PathRef::Work, p))).collect()
}
fn retracted(k: &photocraft_doc::Knot, h: Handle) -> bool {
let c = if h == Handle::In { k.in_ctrl } else { k.out_ctrl };
(c.x - k.anchor.x).abs() < 1e-9 && (c.y - k.anchor.y).abs() < 1e-9
}
/// The handles shown on `path`: both of every selected anchor and the two of a clicked segment
/// (retracted handles sit on their anchor and don't show).
fn shown_handles(ds: &DirectSelection, path: &Path) -> Vec<([usize; 2], Handle)> {
let knot = |r: [usize; 2]| path.subpaths.get(r[0]).and_then(|s| s.knots.get(r[1]));
let mut out: Vec<([usize; 2], Handle)> = ds.anchors.iter().flat_map(|&r| [(r, Handle::In), (r, Handle::Out)]).collect();
if let Some([s, k]) = ds.segment
&& let Some(sp) = path.subpaths.get(s)
{
let n = sp.knots.len();
let next = k.checked_add(1).filter(|&j| j < n).or((sp.closed && n > 1).then_some(0));
out.push(([s, k], Handle::Out));
out.extend(next.map(|j| ([s, j], Handle::In)));
}
out.retain(|&(r, h)| knot(r).is_some_and(|k| !retracted(k, h)));
out
}
/// Canvas pointer events for Direct Selection and the Pen's ⌘ / ⌥ modes; false when the event is
/// not theirs.
pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool {
let tool = app.ui.tool;
match ev {
ToolEvent::Down { x, y, .. } => {
let pen_idle = app.ui.pen.is_none();
// Pen + ⌥ is Convert Point only while no path is being drawn.
let convert = tool == Tool::Pen && mods.alt && !mods.command && pen_idle;
if !(direct(tool, mods) || convert) {
return false;
}
if tool == Tool::Pen && !pen_idle {
// ⌘-click ends the path being drawn, which becomes editable, as in Photoshop.
crate::vector_ui::pen_commit(app, false);
}
press(app, [x, y], mods, convert)
}
ToolEvent::Move { x, y, .. } => {
let ds = &mut app.ui.direct_selection;
match &mut ds.drag {
Some(Drag::Edit { base, edit, start, end, preview, .. }) => {
*end = [x, y];
*preview = edit.preview(base, *start, *end);
}
Some(Drag::Marquee { end, .. }) => *end = [x, y],
None => return tool == Tool::DirectSelection,
}
true
}
ToolEvent::Up { x, y } => match app.ui.direct_selection.drag.take() {
Some(Drag::Edit { target, edit, start, .. }) => {
release(app, target, &edit, start, [x, y]);
true
}
Some(Drag::Marquee { start, add, .. }) => {
marquee(app, start, [x, y], add);
true
}
None => tool == Tool::DirectSelection,
},
}
}
/// A press: pick what's under the pointer and start a drag. False lets the Pen handle a ⌥-press
/// that hit nothing.
fn press(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers, convert: bool) -> bool {
let tol = 6.0 / f64::from(app.current_zoom().max(0.01));
let ds = &app.ui.direct_selection;
let found = candidates(app).into_iter().find_map(|(r, path)| {
let handles = if ds.target == Some(r) { shown_handles(ds, &path) } else { Vec::new() };
edit::hit(&path, Point::new(p[0], p[1]), tol, &handles).map(|h| (r, path, h))
});
let ds = &mut app.ui.direct_selection;
let Some((target, base, hit)) = found else {
if convert {
return false;
}
ds.drag = Some(Drag::Marquee { start: p, end: p, add: mods.shift });
return true;
};
if ds.target != Some(target) {
*ds = DirectSelection { target: Some(target), ..Default::default() };
}
let edit = match hit {
Hit::Handle(k, h) => {
let kn = base.subpaths.get(k[0]).and_then(|s| s.knots.get(k[1]));
let from = kn.map_or(Point::new(p[0], p[1]), |kn| if h == Handle::In { kn.in_ctrl } else { kn.out_ctrl });
Edit::Handle(k, h, from, mods.alt)
}
Hit::Anchor(k) if convert => Edit::Convert(k),
// Pen + ⌥ on a segment does nothing.
Hit::Segment(..) if convert => return true,
Hit::Anchor(k) => {
ds.segment = None;
if mods.alt {
ds.anchors = edit::subpath_anchors(&base, k[0]);
} else if mods.shift {
// ⇧-click toggles the anchor; a click that deselects drags nothing.
if let Some(i) = ds.anchors.iter().position(|a| *a == k) {
ds.anchors.remove(i);
return true;
}
ds.anchors.push(k);
} else if !ds.anchors.contains(&k) {
ds.anchors = vec![k];
}
Edit::Anchors(ds.anchors.clone())
}
Hit::Segment(k, _) if mods.alt => {
ds.segment = None;
ds.anchors = edit::subpath_anchors(&base, k[0]);
Edit::Anchors(ds.anchors.clone())
}
Hit::Segment(k, t) => {
ds.anchors.clear();
ds.segment = Some(k);
Edit::Segment(k, t)
}
};
ds.drag = Some(Drag::Edit { target, base, edit, start: p, end: p, preview: None });
true
}
/// End of an edit drag: commit it as one command. A click (no movement) commits nothing, except
/// Pen + ⌥ on an anchor, which makes it a corner.
fn release(app: &mut PhotocraftApp, target: PathRef, edit: &Edit, start: [f64; 2], end: [f64; 2]) {
let moved = (end[0] - start[0]).hypot(end[1] - start[1]) * f64::from(app.current_zoom()) >= 1.0;
let (id, mut params) = match edit {
Edit::Convert([s, k]) if !moved => ("path.convertPoint", json!({"subpath": s, "knot": k})),
_ if !moved => return,
e => e.command(start, end),
};
if let (Some(p), Some(t)) = (params.as_object_mut(), target.params().as_object()) {
p.extend(t.clone());
}
if let Err(e) = app.run(id, params) {
app.ui.status = e;
app.ui.status_error = true;
}
}
/// End of a marquee: select the anchors inside it on the frontmost path that has any (⇧ adds).
/// A click on empty canvas deselects.
fn marquee(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2], add: bool) {
let rect = [start[0], start[1], end[0], end[1]];
let hits = candidates(app).into_iter().map(|(r, path)| (r, edit::anchors_in(&path, rect))).find(|(_, a)| !a.is_empty());
let ds = &mut app.ui.direct_selection;
match hits {
Some((r, anchors)) if add && ds.target == Some(r) => {
for a in anchors {
if !ds.anchors.contains(&a) {
ds.anchors.push(a);
}
}
ds.segment = None;
}
Some((r, anchors)) => *ds = DirectSelection { target: Some(r), anchors, ..Default::default() },
None if add => {}
None => {
ds.anchors.clear();
ds.segment = None;
}
}
}
/// Paths with their anchors (selected ones filled), the shown handles, the drag's preview in
/// place of its path, and the marquee.
pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, to_scr: &dyn Fn([f64; 2]) -> Pos2, accent: Color32) {
let ds = &app.ui.direct_selection;
for (r, path) in candidates(app) {
let path = match &ds.drag {
Some(Drag::Edit { target, preview: Some(p), .. }) if *target == r => p.clone(),
_ => path,
};
crate::vector_ui::draw_outline(painter, &path, to_scr, accent);
let ours = ds.target == Some(r);
for &(k, h) in if ours { shown_handles(ds, &path) } else { Vec::new() }.iter() {
if let Some(kn) = path.subpaths.get(k[0]).and_then(|s| s.knots.get(k[1])) {
let c = if h == Handle::In { kn.in_ctrl } else { kn.out_ctrl };
crate::vector_ui::draw_handle(painter, to_scr([kn.anchor.x, kn.anchor.y]), to_scr([c.x, c.y]), accent);
}
}
for (s, sp) in path.subpaths.iter().enumerate() {
for (k, kn) in sp.knots.iter().enumerate() {
crate::vector_ui::draw_anchor(painter, to_scr([kn.anchor.x, kn.anchor.y]), ours && ds.anchors.contains(&[s, k]), accent);
}
}
}
if let Some(Drag::Marquee { start, end, .. }) = &ds.drag {
let pts = [*start, [end[0], start[1]], *end, [start[0], end[1]]].map(to_scr);
crate::tool_feedback::draw_ants(painter, &pts, true);
}
}
#[cfg(test)]
#[path = "direct_select_tests.rs"]
mod tests;

View File

@ -0,0 +1,162 @@
//! #790: paths can be reshaped after they're drawn, as in Photoshop: the Direct Selection tool
//! (A), and the Pen with ⌘/Ctrl (Direct Selection) or ⌥/Alt (Convert Point).
use egui::Modifiers;
use photocraft_doc::vector::Path;
use photocraft_geom::Point;
use serde_json::json;
use super::{Drag, PathRef};
use crate::PhotocraftApp;
use crate::canvas::{ToolEvent, tool_event};
use crate::state::Tool;
fn app(tool: Tool) -> PhotocraftApp {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap();
app.sync_views();
app.ui.tool = tool;
app
}
/// A square work path; knot 1 at (120, 40) is smooth with horizontal handles.
fn with_path(app: &mut PhotocraftApp) {
let path = json!({"subpaths": [{"closed": true, "knots": [
[40, 40],
{"anchor": [120, 40], "in": [100, 40], "out": [140, 40], "smooth": true},
[120, 120],
[40, 120],
]}]});
app.run("path.set", json!({"name": "work", "path": path})).unwrap();
}
fn work(app: &PhotocraftApp) -> Path {
app.session.active().unwrap().doc.work_path.clone().unwrap()
}
fn anchor(app: &PhotocraftApp, k: usize) -> Point {
work(app).subpaths[0].knots[k].anchor
}
fn steps(app: &PhotocraftApp) -> usize {
app.session.active().unwrap().history.entries().len()
}
fn drag(app: &mut PhotocraftApp, from: [f64; 2], to: [f64; 2], mods: Modifiers) {
tool_event(app, ToolEvent::Down { x: from[0], y: from[1], pressure: 1.0 }, mods);
let mid = [(from[0] + to[0]) / 2.0, (from[1] + to[1]) / 2.0];
tool_event(app, ToolEvent::Move { x: mid[0], y: mid[1], pressure: 1.0 }, mods);
tool_event(app, ToolEvent::Move { x: to[0], y: to[1], pressure: 1.0 }, mods);
tool_event(app, ToolEvent::Up { x: to[0], y: to[1] }, mods);
}
fn click(app: &mut PhotocraftApp, at: [f64; 2], mods: Modifiers) {
drag(app, at, at, mods);
}
#[test]
fn dragging_an_anchor_moves_it_in_one_undoable_step() {
let mut app = app(Tool::DirectSelection);
with_path(&mut app);
let (before, n) = (work(&app), steps(&app));
drag(&mut app, [40.5, 120.5], [50.5, 130.5], Modifiers::NONE);
assert_eq!(anchor(&app, 3), Point::new(50.0, 130.0));
assert_eq!(anchor(&app, 0), Point::new(40.0, 40.0), "only the dragged anchor moved");
assert_eq!(steps(&app), n + 1, "one history step for the whole drag");
assert_eq!((app.ui.direct_selection.target, app.ui.direct_selection.anchors.clone()), (Some(PathRef::Work), vec![[0, 3]]));
assert!(app.session.undo());
assert_eq!(work(&app), before);
}
#[test]
fn the_preview_is_what_the_drag_commits() {
let mut app = app(Tool::DirectSelection);
with_path(&mut app);
tool_event(&mut app, ToolEvent::Down { x: 80.0, y: 120.0, pressure: 1.0 }, Modifiers::NONE);
tool_event(&mut app, ToolEvent::Move { x: 80.0, y: 150.0, pressure: 1.0 }, Modifiers::NONE);
let Some(Drag::Edit { preview: Some(shown), .. }) = app.ui.direct_selection.drag.clone() else { panic!("a segment drag with a preview") };
assert_ne!(shown, work(&app), "the document changes only on release");
tool_event(&mut app, ToolEvent::Up { x: 80.0, y: 150.0 }, Modifiers::NONE);
assert_eq!(work(&app), shown);
// The straight bottom segment moved with both its anchors.
assert_eq!((anchor(&app, 2), anchor(&app, 3)), (Point::new(120.0, 150.0), Point::new(40.0, 150.0)));
}
#[test]
fn a_smooth_points_handles_turn_together_and_alt_breaks_them() {
let mut app = app(Tool::DirectSelection);
with_path(&mut app);
click(&mut app, [120.0, 40.0], Modifiers::NONE);
assert_eq!(app.ui.direct_selection.anchors, vec![[0, 1]], "a click selects the anchor and shows its handles");
drag(&mut app, [140.0, 40.0], [120.0, 60.0], Modifiers::NONE);
let k = work(&app).subpaths[0].knots[1];
assert_eq!(k.out_ctrl, Point::new(120.0, 60.0));
assert!((k.in_ctrl.x - 120.0).abs() < 1e-9 && (k.in_ctrl.y - 20.0).abs() < 1e-9, "the opposite handle turned with it: {:?}", k.in_ctrl);
drag(&mut app, [120.0, 20.0], [100.0, 20.0], Modifiers::ALT);
let k = work(&app).subpaths[0].knots[1];
assert_eq!((k.in_ctrl, k.out_ctrl, k.smooth), (Point::new(100.0, 20.0), Point::new(120.0, 60.0), false));
}
#[test]
fn marquee_shift_and_empty_clicks_select_like_photoshop() {
let mut app = app(Tool::DirectSelection);
with_path(&mut app);
drag(&mut app, [100.0, 100.0], [180.0, 180.0], Modifiers::NONE);
assert_eq!(app.ui.direct_selection.anchors, vec![[0, 2]]);
click(&mut app, [40.0, 120.0], Modifiers::SHIFT);
assert_eq!(app.ui.direct_selection.anchors, vec![[0, 2], [0, 3]], "⇧-click adds");
// Dragging one selected anchor moves them all.
drag(&mut app, [40.0, 120.0], [40.0, 110.0], Modifiers::NONE);
assert_eq!((anchor(&app, 2), anchor(&app, 3)), (Point::new(120.0, 110.0), Point::new(40.0, 110.0)));
click(&mut app, [40.0, 110.0], Modifiers::SHIFT);
assert_eq!(app.ui.direct_selection.anchors, vec![[0, 2]], "⇧-click on a selected anchor deselects it");
click(&mut app, [80.0, 80.0], Modifiers::NONE);
assert!(app.ui.direct_selection.anchors.is_empty(), "a click on empty canvas deselects");
click(&mut app, [80.0, 40.0], Modifiers::ALT);
assert_eq!(app.ui.direct_selection.anchors.len(), 4, "⌥-click selects the whole subpath");
}
#[test]
fn pen_with_command_edits_paths_and_ends_the_one_being_drawn() {
let mut app = app(Tool::Pen);
for p in [[30.0, 30.0], [90.0, 30.0]] {
click(&mut app, p, Modifiers::NONE);
}
assert_eq!(app.ui.pen.as_ref().map(|p| p.knots.len()), Some(2));
// ⌘-drag (Ctrl-drag off the Mac) on the second anchor: the path is finished and edited.
drag(&mut app, [90.0, 30.0], [90.0, 70.0], Modifiers::COMMAND);
assert!(app.ui.pen.is_none());
assert_eq!(app.ui.tool, Tool::Pen, "the Pen stays the tool");
let wp = work(&app);
assert_eq!((wp.subpaths[0].closed, wp.subpaths[0].knots[1].anchor), (false, Point::new(90.0, 70.0)));
// Without ⌘ the Pen draws again.
click(&mut app, [150.0, 150.0], Modifiers::NONE);
assert_eq!(app.ui.pen.as_ref().map(|p| p.knots.len()), Some(1));
}
#[test]
fn pen_with_alt_converts_points() {
let mut app = app(Tool::Pen);
with_path(&mut app);
click(&mut app, [120.0, 40.0], Modifiers::ALT);
assert_eq!(work(&app).subpaths[0].knots[1], photocraft_doc::Knot::corner(120.0, 40.0), "⌥-click makes a corner");
drag(&mut app, [40.0, 120.0], [40.0, 140.0], Modifiers::ALT);
let k = work(&app).subpaths[0].knots[3];
assert_eq!((k.in_ctrl, k.out_ctrl, k.smooth), (Point::new(40.0, 100.0), Point::new(40.0, 140.0), true), "⌥-drag pulls out smooth handles");
assert!(app.ui.pen.is_none(), "no pen path was started");
// ⌥ off the path is a plain Pen click.
click(&mut app, [170.0, 170.0], Modifiers::ALT);
assert!(app.ui.pen.is_some());
}
#[test]
fn shape_layer_paths_are_edited_too() {
let mut app = app(Tool::DirectSelection);
let id = app.run("shape.create", json!({"kind": "rect", "rect": [20, 20, 60, 60], "fill": "#3070c0"})).unwrap()["layer"].as_u64().unwrap();
drag(&mut app, [80.0, 80.0], [100.0, 90.0], Modifiers::NONE);
assert_eq!(app.ui.direct_selection.target, Some(PathRef::Layer(id)));
let info = app.run("shape.info", json!({"layer": id})).unwrap();
assert_eq!(info["path"]["subpaths"][0]["knots"][2]["anchor"], json!([100.0, 90.0]));
let b: [i64; 4] = serde_json::from_value(info["bounds"].clone()).unwrap();
assert!(b[0] + b[2] >= 100 && b[1] + b[3] >= 90, "the shape re-rendered: {b:?}");
}

View File

@ -981,6 +981,7 @@
Drag a rectangle around the object Táhněte obdélník kolem objektu
Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Tažením v obraze přidáte omezení; Shift pro vodorovné/svislé; pravým kliknutím odstraníte.
Drag to move the active shape's path or the Work Path Tažením přesunete cestu aktivního tvaru nebo pracovní cestu
Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Klik: vybrat bod · Tažení: přesunout bod, úchyt nebo segment · {shift}+klik: přidat · {alt}+klik: celá podcesta
Draw with the Pen tool (P) or make a work path from a selection. Kreslete nástrojem pero (P) nebo vytvořte pracovní cestu z výběru.
Drop an image or PSD anywhere to open it. Přetáhněte sem obrázek nebo PSD a otevře se.
Native file drag-and-drop is unavailable Nativní přetahování souborů ve Waylandu není dostupné
@ -1609,6 +1610,7 @@
Paint Bucket Tool Nástroj plechovka barvy
Paints from the document's opening state Maluje ze stavu dokumentu při otevření
Path Selection Tool Nástroj výběr cesty
Direct Selection Tool Nástroj přímý výběr
Pattern Overlay Překrytí vzorkem
Pen Pressure Přítlak pera
Pen Tilt Náklon pera

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -964,6 +964,7 @@
Drag a rectangle around the object Ziehe ein Rechteck um das Objekt
Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Ziehe auf dem Bild, um eine Zwangsbedingung hinzuzufügen; Shift für horizontal/vertikal; Rechtsklick löscht.
Drag to move the active shape's path or the Work Path Ziehen, um den Pfad der aktiven Form oder den Arbeitspfad zu verschieben
Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Klick: Ankerpunkt auswählen · Ziehen: Ankerpunkt, Griff oder Segment verschieben · {shift}-Klick: hinzufügen · {alt}-Klick: ganzer Teilpfad
Draw with the Pen tool (P) or make a work path from a selection. Zeichne mit dem Stift-Werkzeug (P) oder erzeuge einen Arbeitspfad aus einer Auswahl.
Drop an image or PSD anywhere to open it. Ziehe ein Bild oder PSD an eine beliebige Stelle, um es zu öffnen.
Native file drag-and-drop is unavailable Natives Ziehen und Ablegen von Dateien ist nicht verfügbar
@ -1590,6 +1591,7 @@
Paint Bucket Tool Fülleimer-Werkzeug
Paints from the document's opening state Malt aus dem Zustand beim Öffnen des Dokuments
Path Selection Tool Pfadauswahl-Werkzeug
Direct Selection Tool Direktauswahl-Werkzeug
Pattern Overlay Musterüberlagerung
Pen Pressure Stiftdruck
Pen Tilt Stiftneigung

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -966,6 +966,7 @@
Drag a rectangle around the object Arrastre un rectángulo alrededor del objeto
Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Arrastre sobre la imagen para añadir una restricción; Mayús para horizontal/vertical; clic derecho para eliminar.
Drag to move the active shape's path or the Work Path Arrastre para mover el trazado de la forma activa o el Trazado de trabajo
Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Clic: seleccionar punto · Arrastrar: mover punto, manejador o segmento · {shift}-clic: añadir · {alt}-clic: subtrazado completo
Draw with the Pen tool (P) or make a work path from a selection. Dibuja con la herramienta Pluma (P) o crea un trazado de trabajo a partir de una selección.
Drop an image or PSD anywhere to open it. Suelte una imagen o un PSD en cualquier lugar para abrirlo.
Native file drag-and-drop is unavailable El arrastrar y soltar archivos de forma nativa no está disponible en Wayland
@ -1595,6 +1596,7 @@
Paint Bucket Tool Herramienta Bote de pintura
Paints from the document's opening state Pinta a partir del estado en que se abrió el documento.
Path Selection Tool Herramienta Selección de trazado
Direct Selection Tool Herramienta Selección directa
Pattern Overlay Superposición de patrón
Pen Pressure Presión del lápiz
Pen Tilt Inclinación del lápiz

Can't render this file because it contains an unexpected character in line 4 and column 47.

View File

@ -956,6 +956,7 @@
Drag a rectangle around the object Tracez un rectangle autour de l'objet
Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Faites glisser sur l'image pour ajouter une contrainte ; Maj pour horizontal/vertical ; clic droit pour supprimer.
Drag to move the active shape's path or the Work Path Faites glisser pour déplacer le tracé de la forme active ou le tracé de travail
Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Clic : sélectionner le point · Glisser : déplacer point, poignée ou segment · {shift}-clic : ajouter · {alt}-clic : sous-tracé entier
Draw with the Pen tool (P) or make a work path from a selection. Dessinez avec l'outil Plume (P) ou créez un tracé de travail à partir d'une sélection.
Drop an image or PSD anywhere to open it. Déposez une image ou un PSD n'importe où pour l'ouvrir.
Duplicate Layers… Dupliquer les calques…
@ -1579,6 +1580,7 @@
Paint Bucket Tool Outil Pot de peinture
Paints from the document's opening state Peint à partir de l'état d'ouverture du document
Path Selection Tool Outil Sélection de tracé
Direct Selection Tool Outil Sélection directe
Pattern Overlay Superposition de motif
Pen Pressure Pression du stylet
Pen Tilt Inclinaison du stylet

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -950,6 +950,7 @@
Drag a rectangle around the object Seret persegi panjang di sekeliling objek
Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Seret pada gambar untuk menambah batasan; Shift untuk horizontal/vertikal; klik kanan untuk menghapus.
Drag to move the active shape's path or the Work Path Seret untuk memindah path bentuk aktif atau Work Path
Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Klik: pilih titik jangkar · Seret: pindahkan titik, gagang, atau segmen · {shift}-klik: tambah · {alt}-klik: seluruh subpath
Draw with the Pen tool (P) or make a work path from a selection. Gambar dengan alat Pen (P) atau buat work path dari seleksi.
Drop an image or PSD anywhere to open it. Jatuhkan gambar atau PSD di mana saja untuk membukanya.
Duplicate Layers… Duplikat Layer…
@ -1573,6 +1574,7 @@
Paint Bucket Tool Alat Paint Bucket
Paints from the document's opening state Melukis dari kondisi awal dokumen saat dibuka
Path Selection Tool Alat Path Selection
Direct Selection Tool Alat Direct Selection
Pattern Overlay Overlay Pola
Pen Pressure Tekanan Pena
Pen Tilt Kemiringan Pena

Can't render this file because it contains an unexpected character in line 4 and column 47.

View File

@ -965,6 +965,7 @@
Drag a rectangle around the object Trascina un rettangolo attorno all'oggetto
Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Trascina sull'immagine per aggiungere un vincolo; Maiusc per orizz/vert; click destro per eliminare.
Drag to move the active shape's path or the Work Path Trascina per spostare il tracciato forma attiva o il Tracciato di lavoro
Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Clic: seleziona punto · Trascina: sposta punto, maniglia o segmento · {shift}-clic: aggiungi · {alt}-clic: intero sottotracciato
Draw with the Pen tool (P) or make a work path from a selection. Disegna con Penna (P) o crea un tracciato di lavoro da una selezione.
Drop an image or PSD anywhere to open it. Trascina un'immagine o PSD ovunque per aprirlo.
Native file drag-and-drop is unavailable Il drag-and-drop nativo file non è disponibile su Wayland
@ -1593,6 +1594,7 @@
Paint Bucket Tool Strumento Secchiello
Paints from the document's opening state Dipinge dallo stato di apertura del documento
Path Selection Tool Strumento Selezione tracciato
Direct Selection Tool Strumento Selezione diretta
Pattern Overlay Sovrapposizione pattern
Pen Pressure Pressione penna
Pen Tilt Inclinazione penna

Can't render this file because it has a wrong number of fields in line 14.

View File

@ -971,6 +971,7 @@
Drag a rectangle around the object オブジェクトを囲む矩形をドラッグ
Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. 画像上をドラッグして制約を追加。Shift で水平/垂直、右クリックで削除。
Drag to move the active shape's path or the Work Path ドラッグして、アクティブなシェイプのパスまたは作業用パスを移動
Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath クリック: アンカーポイントを選択 · ドラッグ: ポイント、ハンドル、セグメントを移動 · {shift}+クリック: 追加 · {alt}+クリック: サブパス全体
Draw with the Pen tool (P) or make a work path from a selection. ペンツール(P)で描くか、選択範囲から作業用パスを作成します。
Drop an image or PSD anywhere to open it. 画像や PSD をここにドロップすると開きます。
Native file drag-and-drop is unavailable Wayland ではネイティブのファイルドラッグ&ドロップを利用できません
@ -1600,6 +1601,7 @@
Paint Bucket Tool 塗りつぶしツール
Paints from the document's opening state ドキュメントを開いた時点の状態から描画します
Path Selection Tool パスコンポーネント選択ツール
Direct Selection Tool パス選択ツール
Pattern Overlay パターンオーバーレイ
Pen Pressure ペンの筆圧
Pen Tilt ペンの傾き

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -951,6 +951,7 @@
Drag a rectangle around the object 오브젝트를 둘러싸는 사각형을 드래그하세요
Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. 이미지 위에서 드래그하여 제약 조건 추가; Shift 키로 수평/수직 유지; 오른쪽 클릭하여 삭제.
Drag to move the active shape's path or the Work Path 드래그하여 활성 모양의 패스 또는 작업 패스 이동
Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath 클릭: 기준점 선택 · 드래그: 기준점, 핸들 또는 세그먼트 이동 · {shift}-클릭: 추가 · {alt}-클릭: 하위 패스 전체
Draw with the Pen tool (P) or make a work path from a selection. 펜 도구(P)로 그리거나 선택 영역에서 작업 패스를 만드세요.
Drop an image or PSD anywhere to open it. 이미지나 PSD를 아무 곳에나 놓아 여세요.
Duplicate Layers… 레이어 복제…
@ -1574,6 +1575,7 @@
Paint Bucket Tool 페인트 통 도구
Paints from the document's opening state 문서를 열었을 때의 상태로 칠하기
Path Selection Tool 패스 선택 도구
Direct Selection Tool 직접 선택 도구
Pattern Overlay 패턴 오버레이
Pen Pressure 펜 압력
Pen Tilt 펜 기울기

Can't render this file because it has a wrong number of fields in line 10.

View File

@ -955,6 +955,7 @@
Drag a rectangle around the object Arraste um retângulo ao redor do objeto
Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Arraste sobre a imagem para adicionar uma restrição; Shift para horizontal/vertical; clique direito para excluir.
Drag to move the active shape's path or the Work Path Arraste para mover o demarcador da forma ativa ou o demarcador de trabalho
Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Clique: selecionar ponto · Arrastar: mover ponto, alça ou segmento · {shift}-clique: adicionar · {alt}-clique: subdemarcador inteiro
Draw with the Pen tool (P) or make a work path from a selection. Desenhe com a ferramenta Caneta (P) ou crie um demarcador de trabalho a partir de uma seleção.
Drop an image or PSD anywhere to open it. Solte uma imagem ou um PSD em qualquer lugar para abri-lo.
Duplicate Layers… Duplicar camadas…
@ -1578,6 +1579,7 @@
Paint Bucket Tool Ferramenta Lata de tinta
Paints from the document's opening state Pinta a partir do estado de abertura do documento
Path Selection Tool Ferramenta Seleção de demarcador
Direct Selection Tool Ferramenta Seleção direta
Pattern Overlay Sobreposição de padrão
Pen Pressure Pressão da caneta
Pen Tilt Inclinação da caneta

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -1083,6 +1083,7 @@
Vector Mask from Current Path Векторная маска из текущего контура
Create new shape from the current path Создать новую фигуру из текущего контура
Drag to move the active shape's path or the Work Path Перетащите, чтобы переместить контур активной фигуры или рабочий контур
Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Щелчок: выбрать узел · Перетаскивание: переместить узел, маркер или сегмент · {shift}-щелчок: добавить · {alt}-щелчок: весь подконтур
Draw with the Pen tool (P) or make a work path from a selection. Нарисуйте пером (P) или создайте рабочий контур из выделения.
# Clone Source panel
@ -1495,6 +1496,7 @@
Object Selection Tool Выделение объектов
Pen Tool Перо
Path Selection Tool Выделение контура
Direct Selection Tool Выделение узла
Rectangle Tool Прямоугольник
Ellipse Tool Овал
Triangle Tool Треугольник

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -971,6 +971,7 @@
Drag a rectangle around the object 围绕对象拖出矩形
Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. 在图像上拖动以添加约束;按 Shift 保持水平或垂直;右键单击删除。
Drag to move the active shape's path or the Work Path 拖动以移动当前形状的路径或工作路径
Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath 单击:选择锚点 · 拖动:移动锚点、手柄或线段 · {shift}-单击:添加 · {alt}-单击:整个子路径
Draw with the Pen tool (P) or make a work path from a selection. 使用钢笔工具(P)绘制,或从选区创建工作路径。
Drop an image or PSD anywhere to open it. 将图像或 PSD 拖放到任意位置即可打开。
Native file drag-and-drop is unavailable Wayland 上不支持原生文件拖放
@ -1606,6 +1607,7 @@
Paint Bucket Tool 油漆桶工具
Paints from the document's opening state 从文档打开时的状态绘制
Path Selection Tool 路径选择工具
Direct Selection Tool 直接选择工具
Pattern Overlay 图案叠加
Pen Pressure 钢笔压力
Pen Tilt 钢笔倾斜

Can't render this file because it has a wrong number of fields in line 14.

View File

@ -972,6 +972,7 @@
Drag a rectangle around the object 在物件周圍拖曳出矩形
Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. 在影像上拖曳以新增限制線;按住 Shift 可限制水平/垂直;按右鍵可刪除。
Drag to move the active shape's path or the Work Path 拖曳以移動作用中形狀的路徑或工作路徑
Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath 按一下:選取錨點 · 拖曳:移動錨點、把手或線段 · {shift}-按一下:增加 · {alt}-按一下:整個子路徑
Draw with the Pen tool (P) or make a work path from a selection. 使用筆型工具 (P) 繪製,或從選取範圍建立工作路徑。
Drop an image or PSD anywhere to open it. 將影像或 PSD 拖放到任何位置即可開啟。
Native file drag-and-drop is unavailable Wayland 不支援原生檔案拖放
@ -1599,6 +1600,7 @@
Paint Bucket Tool 油漆桶工具
Paints from the document's opening state 從文件開啟時的狀態繪製
Path Selection Tool 路徑選取工具
Direct Selection Tool 直接選取工具
Pattern Overlay 圖樣覆蓋
Pen Pressure 筆壓
Pen Tilt 筆傾斜

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -28,6 +28,7 @@ pub static ICONS: &[(&str, &[u8])] = &[
("copy", include_bytes!("../../../assets/icons/copy.svg")),
("crop", include_bytes!("../../../assets/icons/crop.svg")),
("diamond", include_bytes!("../../../assets/icons/diamond.svg")),
("direct-select", include_bytes!("../../../assets/icons/direct-select.svg")),
("droplet", include_bytes!("../../../assets/icons/droplet.svg")),
("ellipsis", include_bytes!("../../../assets/icons/ellipsis.svg")),
("eraser", include_bytes!("../../../assets/icons/eraser.svg")),

View File

@ -99,6 +99,7 @@ pub fn tool_icon(t: Tool) -> &'static str {
Tool::ObjectSelection => "square-dashed-mouse-pointer",
Tool::Pen => "pen-tool",
Tool::PathSelection => "mouse-pointer-2",
Tool::DirectSelection => "direct-select",
Tool::Rectangle => "rectangle-horizontal",
Tool::EllipseShape => "circle",
Tool::Triangle => "triangle",

View File

@ -44,6 +44,7 @@ pub mod control;
pub mod credits;
pub mod crop_ui;
pub mod dialogs;
pub mod direct_select;
pub mod discard_ui;
pub mod distort_ui;
pub mod doc_props_ui;

View File

@ -36,7 +36,7 @@ const TOOL_SECTIONS: &[&[&[Tool]]] = &[
&[
&[Tool::Pen],
&[Tool::Type, Tool::VerticalType],
&[Tool::PathSelection],
&[Tool::PathSelection, Tool::DirectSelection],
&[Tool::Rectangle, Tool::EllipseShape, Tool::Triangle, Tool::Polygon, Tool::Line, Tool::CustomShape],
],
&[&[Tool::Hand], &[Tool::Zoom]],

View File

@ -102,6 +102,7 @@ pub enum Tool {
ObjectSelection,
Pen,
PathSelection,
DirectSelection,
Rectangle,
EllipseShape,
Triangle,
@ -113,7 +114,7 @@ pub enum Tool {
}
impl Tool {
pub const ALL: [Tool; 48] = [
pub const ALL: [Tool; 49] = [
Tool::Move,
Tool::RectMarquee,
Tool::EllipseMarquee,
@ -154,6 +155,7 @@ impl Tool {
Tool::ObjectSelection,
Tool::Pen,
Tool::PathSelection,
Tool::DirectSelection,
Tool::Rectangle,
Tool::EllipseShape,
Tool::Triangle,
@ -208,6 +210,7 @@ impl Tool {
Tool::ObjectSelection => "Object Selection Tool",
Tool::Pen => "Pen Tool",
Tool::PathSelection => "Path Selection Tool",
Tool::DirectSelection => "Direct Selection Tool",
Tool::Rectangle => "Rectangle Tool",
Tool::EllipseShape => "Ellipse Tool",
Tool::Triangle => "Triangle Tool",
@ -263,7 +266,7 @@ impl Tool {
Tool::Dodge | Tool::Burn | Tool::Sponge => 'O',
Tool::QuickSelection | Tool::ObjectSelection => 'W',
Tool::Pen => 'P',
Tool::PathSelection => 'A',
Tool::PathSelection | Tool::DirectSelection => 'A',
Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::Line | Tool::CustomShape => 'U',
}
}
@ -739,6 +742,9 @@ pub struct UiState {
/// Pen path under construction.
#[serde(default)]
pub pen: Option<crate::vector_ui::PenPath>,
/// Direct Selection tool: selected anchors and the drag in progress (#790).
#[serde(default)]
pub direct_selection: crate::direct_select::DirectSelection,
/// Selected row in the Paths panel ("work" or a saved path name).
#[serde(default)]
pub selected_path: Option<String>,
@ -863,6 +869,7 @@ impl Default for UiState {
shell: Default::default(),
layer_filter: Vec::new(),
pen: None,
direct_selection: Default::default(),
selected_path: None,
panels: Panels::default(),
views: Vec::new(),

View File

@ -1,4 +1,5 @@
//! Vector tools: Shape tools (U), Pen (P), Path Selection (A), path overlays and the Paths panel.
//! Direct Selection (A) lives in `direct_select`.
//! All edits go through the engine's `shape.*` / `path.*` commands.
use egui::{Color32, Pos2, Rect, Sense, Stroke, pos2, vec2};
@ -225,26 +226,33 @@ enum PathTarget {
}
/// The targeted vector mask of the active layer, if the Layers panel targets it.
fn targeted_vector_mask(app: &PhotocraftApp) -> Option<(u64, Path)> {
pub(crate) fn targeted_vector_mask(app: &PhotocraftApp) -> Option<(u64, Path)> {
let st = app.session.active()?;
let l = st.active_layer.and_then(|id| st.doc.layer(id))?;
(app.ui.vector_mask_target && !matches!(l.content, LayerContent::Shape(_))).then_some(())?;
l.vector_mask.as_ref().map(|m| (l.id.0, m.path.clone()))
}
/// The active shape layer's path, if the active layer is a shape layer.
pub(crate) fn active_shape_path(app: &PhotocraftApp) -> Option<(u64, Path)> {
let st = app.session.active()?;
let l = st.active_layer.and_then(|id| st.doc.layer(id))?;
match &l.content {
LayerContent::Shape(sh) => Some((l.id.0, sh.path.clone())),
_ => None,
}
}
/// The path Path Selection edits: the targeted vector mask, the active shape layer's path, else
/// the work path.
fn target_path(app: &PhotocraftApp) -> Option<(PathTarget, Path)> {
if let Some((id, p)) = targeted_vector_mask(app) {
return Some((PathTarget::VectorMask(id), p));
}
let st = app.session.active()?;
if let Some(l) = st.active_layer.and_then(|id| st.doc.layer(id))
&& let LayerContent::Shape(sh) = &l.content
{
return Some((PathTarget::Shape(l.id.0), sh.path.clone()));
if let Some((id, p)) = active_shape_path(app) {
return Some((PathTarget::Shape(id), p));
}
st.doc.work_path.clone().map(|p| (PathTarget::Work, p))
app.session.active()?.doc.work_path.clone().map(|p| (PathTarget::Work, p))
}
pub fn path_selection_finish(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) {
@ -276,7 +284,7 @@ fn bezier(p0: [f64; 2], c0: [f64; 2], c1: [f64; 2], p1: [f64; 2], t: f64) -> [f6
}
/// Screen polylines for a path (one per subpath).
fn path_lines(path: &Path, xf: &dyn Fn([f64; 2]) -> Pos2) -> Vec<(Vec<Pos2>, bool)> {
pub(crate) fn path_lines(path: &Path, xf: &dyn Fn([f64; 2]) -> Pos2) -> Vec<(Vec<Pos2>, bool)> {
path.subpaths
.iter()
.map(|s| {
@ -301,6 +309,30 @@ fn path_lines(path: &Path, xf: &dyn Fn([f64; 2]) -> Pos2) -> Vec<(Vec<Pos2>, boo
.collect()
}
/// A path's outline in the accent colour.
pub(crate) fn draw_outline(painter: &egui::Painter, p: &Path, to_scr: &dyn Fn([f64; 2]) -> Pos2, accent: Color32) {
for (pts, closed) in path_lines(p, to_scr) {
if closed {
painter.add(egui::Shape::closed_line(pts, Stroke::new(1.0, accent)));
} else {
painter.add(egui::Shape::line(pts, Stroke::new(1.0, accent)));
}
}
}
/// An anchor point's square: filled with the accent when selected, else hollow (white).
pub(crate) fn draw_anchor(painter: &egui::Painter, c: Pos2, selected: bool, accent: Color32) {
let r = Rect::from_center_size(c, vec2(6.0, 6.0));
painter.rect_filled(r, 0.0, if selected { accent } else { Color32::WHITE });
painter.rect_stroke(r, 0.0, Stroke::new(1.0, accent), egui::StrokeKind::Inside);
}
/// A direction handle: the line from its anchor and the round handle end.
pub(crate) fn draw_handle(painter: &egui::Painter, anchor: Pos2, handle: Pos2, accent: Color32) {
painter.line_segment([anchor, handle], Stroke::new(1.0, accent));
painter.circle_filled(handle, 3.0, accent);
}
/// Work path / active shape path outlines, anchors, and the pen path in progress.
pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, doc: &Document) {
let tool = app.ui.tool;
@ -308,38 +340,33 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform
let accent = Tokens::get(painter.ctx()).accent;
let to_scr = |q: [f64; 2]| xf.to_screen(q[0] as f32, q[1] as f32);
let draw_path = |p: &Path, anchors: bool| {
for (pts, closed) in path_lines(p, &to_scr) {
if closed {
painter.add(egui::Shape::closed_line(pts, Stroke::new(1.0, accent)));
} else {
painter.add(egui::Shape::line(pts, Stroke::new(1.0, accent)));
}
}
draw_outline(painter, p, &to_scr, accent);
if anchors {
for s in &p.subpaths {
for k in &s.knots {
let c = to_scr([k.anchor.x, k.anchor.y]);
painter.rect_filled(Rect::from_center_size(c, vec2(6.0, 6.0)), 0.0, Color32::WHITE);
painter.rect_stroke(Rect::from_center_size(c, vec2(6.0, 6.0)), 0.0, Stroke::new(1.0, accent), egui::StrokeKind::Inside);
}
for k in p.subpaths.iter().flat_map(|s| &s.knots) {
draw_anchor(painter, to_scr([k.anchor.x, k.anchor.y]), false, accent);
}
}
};
if vector_tool {
if let Some(wp) = &doc.work_path {
draw_path(wp, tool == Tool::PathSelection);
if crate::direct_select::shows(app, painter.ctx().input(|i| i.modifiers)) {
// Direct Selection (or the Pen with ⌘/Ctrl held) draws the paths it edits (#790).
crate::direct_select::draw_overlay(app, painter, &to_scr, accent);
} else {
if vector_tool {
if let Some(wp) = &doc.work_path {
draw_path(wp, tool == Tool::PathSelection);
}
let st = app.session.active();
if let Some(l) = st.and_then(|s| s.active_layer.and_then(|id| s.doc.layer(id)))
&& let LayerContent::Shape(sh) = &l.content
{
draw_path(&sh.path, tool == Tool::PathSelection);
}
}
let st = app.session.active();
if let Some(l) = st.and_then(|s| s.active_layer.and_then(|id| s.doc.layer(id)))
&& let LayerContent::Shape(sh) = &l.content
{
draw_path(&sh.path, tool == Tool::PathSelection);
// A targeted vector mask shows its path with any tool (#196).
if let Some((_, p)) = targeted_vector_mask(app) {
draw_path(&p, tool == Tool::PathSelection);
}
}
// A targeted vector mask shows its path with any tool (#196).
if let Some((_, p)) = targeted_vector_mask(app) {
draw_path(&p, tool == Tool::PathSelection);
}
// Pen path in progress, with handles of the last knot and a rubber band to the pointer.
if let Some(pen) = &app.ui.pen {
let mut pts = Vec::new();
@ -356,16 +383,13 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform
}
for (i, k) in pen.knots.iter().enumerate() {
let c = to_scr(k[0]);
if i + 1 == pen.knots.len() && k[2] != k[0] {
let last = i + 1 == pen.knots.len();
if last && k[2] != k[0] {
for hnd in [k[1], k[2]] {
let hp = to_scr(hnd);
painter.line_segment([c, hp], Stroke::new(1.0, accent));
painter.circle_filled(hp, 3.0, accent);
draw_handle(painter, c, to_scr(hnd), accent);
}
}
let r = Rect::from_center_size(c, vec2(6.0, 6.0));
painter.rect_filled(r, 0.0, if i + 1 == pen.knots.len() { accent } else { Color32::WHITE });
painter.rect_stroke(r, 0.0, Stroke::new(1.0, accent), egui::StrokeKind::Inside);
draw_anchor(painter, c, last, accent);
}
}
}
@ -375,7 +399,7 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform
/// Options bar for vector tools; false for other tools.
pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bool {
if !(is_shape_tool(tool) || matches!(tool, Tool::Pen | Tool::PathSelection)) {
if !(is_shape_tool(tool) || matches!(tool, Tool::Pen | Tool::PathSelection | Tool::DirectSelection)) {
return false;
}
let t = Tokens::get(ui.ctx());
@ -387,6 +411,17 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo
lbl(ui, if app.ui.vector_mask_target { "Drag to move the targeted vector mask" } else { tl!("Drag to move the active shape's path or the Work Path") });
return true;
}
if tool == Tool::DirectSelection {
let (shift, alt) = (crate::shortcuts::pretty("Shift"), crate::shortcuts::pretty("Alt"));
lbl(
ui,
&crate::i18n::fmt(
tl!("Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath"),
&[("shift", &shift), ("alt", &alt)],
),
);
return true;
}
if tool == Tool::Pen {
let opts = [("path".to_string(), tl!("Path")), ("shape".to_string(), tl!("Shape"))];
crate::widgets::dropdown(ui, "pen-mode", &mut o.vector_mode, &opts, 80.0);

341
crates/vector/src/edit.rs Normal file
View File

@ -0,0 +1,341 @@
//! Point-level path editing, Photoshop's Direct Selection and Convert Point tools: move anchors,
//! drag direction handles, reshape a segment by dragging it, convert between smooth and corner
//! points, and hit-test a path's parts. Pure geometry on [`Path`]; the engine's `path.*` edit
//! commands and the canvas previews share it, so a preview is exactly what the command commits.
//!
//! Knots are addressed as `[subpath, knot]`. Every edit validates its indices and returns an
//! error message instead of panicking.
use photocraft_doc::{Knot, Path};
use photocraft_geom::Point;
/// One of a knot's two direction handles.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Handle {
/// The control point of the segment arriving at the knot.
In,
/// The control point of the segment leaving the knot.
Out,
}
impl Handle {
pub fn parse(s: &str) -> Option<Handle> {
match s {
"in" => Some(Handle::In),
"out" => Some(Handle::Out),
_ => None,
}
}
pub fn name(self) -> &'static str {
match self {
Handle::In => "in",
Handle::Out => "out",
}
}
}
/// The part of a path under the pointer.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Hit {
Anchor([usize; 2]),
Handle([usize; 2], Handle),
/// The segment leaving knot `[subpath, knot]`, at curve parameter `t`.
Segment([usize; 2], f64),
}
fn add(p: Point, d: [f64; 2]) -> Point {
Point::new(p.x + d[0], p.y + d[1])
}
fn dist(a: Point, b: Point) -> f64 {
(a.x - b.x).hypot(a.y - b.y)
}
fn knot_mut(path: &mut Path, [s, k]: [usize; 2]) -> Result<&mut Knot, String> {
path.subpaths.get_mut(s).and_then(|sp| sp.knots.get_mut(k)).ok_or_else(|| format!("no knot {k} in subpath {s}"))
}
/// The knot ending the segment that leaves `[s, k]` (wrapping on closed subpaths).
fn segment_end(path: &Path, [s, k]: [usize; 2]) -> Result<[usize; 2], String> {
let sp = path.subpaths.get(s).ok_or_else(|| format!("no subpath {s}"))?;
let n = sp.knots.len();
match k.checked_add(1) {
Some(next) if k < n && next < n => Ok([s, next]),
Some(_) if k < n && sp.closed && n > 1 => Ok([s, 0]),
_ => Err(format!("no segment leaves knot {k} of subpath {s}")),
}
}
fn handle_mut(k: &mut Knot, h: Handle) -> &mut Point {
match h {
Handle::In => &mut k.in_ctrl,
Handle::Out => &mut k.out_ctrl,
}
}
fn handle(k: &Knot, h: Handle) -> Point {
match h {
Handle::In => k.in_ctrl,
Handle::Out => k.out_ctrl,
}
}
fn other(h: Handle) -> Handle {
match h {
Handle::In => Handle::Out,
Handle::Out => Handle::In,
}
}
/// A smooth knot keeps its handles collinear: point handle `h`'s opposite away from it, keeping
/// the opposite's length (a retracted handle stays retracted, as in Photoshop).
fn keep_smooth(k: &mut Knot, h: Handle) {
if !k.smooth {
return;
}
let (a, moved) = (k.anchor, handle(k, h));
let opp = handle_mut(k, other(h));
let (len, d) = (dist(*opp, a), dist(moved, a));
if len > 1e-9 && d > 1e-9 {
*opp = Point::new(a.x - (moved.x - a.x) / d * len, a.y - (moved.y - a.y) / d * len);
}
}
/// Moves anchors (with their handles) by `d`. Each knot moves once, however often it is listed.
pub fn move_anchors(path: &mut Path, anchors: &[[usize; 2]], d: [f64; 2]) -> Result<(), String> {
let mut list = anchors.to_vec();
list.sort_unstable();
list.dedup();
for r in &list {
knot_mut(path, *r)?;
}
for r in list {
let k = knot_mut(path, r)?;
*k = Knot { anchor: add(k.anchor, d), in_ctrl: add(k.in_ctrl, d), out_ctrl: add(k.out_ctrl, d), smooth: k.smooth };
}
Ok(())
}
/// Puts handle `h` of a knot at `to`. A smooth knot turns its other handle to stay collinear,
/// unless `independent` (Convert Point / ⌥), which makes it a corner whose handles move apart.
pub fn move_handle(path: &mut Path, knot: [usize; 2], h: Handle, to: Point, independent: bool) -> Result<(), String> {
let k = knot_mut(path, knot)?;
*handle_mut(k, h) = to;
if independent {
k.smooth = false;
}
keep_smooth(k, h);
Ok(())
}
/// Convert Point: without `out`, a corner with retracted handles (a click on an anchor); with
/// `out`, a smooth knot whose handles are `out` and its mirror (a drag from an anchor).
pub fn convert_point(path: &mut Path, knot: [usize; 2], out: Option<Point>) -> Result<(), String> {
let k = knot_mut(path, knot)?;
let a = k.anchor;
*k = match out {
Some(o) => Knot::smooth(a, Point::new(2.0 * a.x - o.x, 2.0 * a.y - o.y), o),
None => Knot::corner(a.x, a.y),
};
Ok(())
}
/// Drags the segment leaving `knot` at curve parameter `t` by `d`. A straight segment (both
/// handles retracted) moves with its two anchors; a curve reshapes so the point at `t` follows
/// the pointer exactly, with the nearer handle moving more. Smooth end knots stay smooth.
pub fn bend_segment(path: &mut Path, knot: [usize; 2], t: f64, d: [f64; 2]) -> Result<(), String> {
let end = segment_end(path, knot)?;
let retracted = |k: &Knot, h: Handle| dist(handle(k, h), k.anchor) < 1e-9;
let a = *knot_mut(path, knot)?;
let b = *knot_mut(path, end)?;
if retracted(&a, Handle::Out) && retracted(&b, Handle::In) {
return move_anchors(path, &[knot, end], d);
}
// B(t) moves by 3(1−t)²t·Δc1 + 3(1−t)t²·Δc2. With Δc1 = (1−t)·s·d and Δc2 = t·s·d that is
// 3t(1−t)((1−t)² + t²)·s·d, so s below makes it exactly d. The ends are clamped: a drag right
// at an anchor would need an unbounded handle move.
let t = if t.is_finite() { t.clamp(0.05, 0.95) } else { 0.5 };
let u = 1.0 - t;
let s = 1.0 / (3.0 * t * u * (u * u + t * t));
let (w1, w2) = (u * s, t * s);
let ka = knot_mut(path, knot)?;
ka.out_ctrl = add(ka.out_ctrl, [d[0] * w1, d[1] * w1]);
keep_smooth(ka, Handle::Out);
let kb = knot_mut(path, end)?;
kb.in_ctrl = add(kb.in_ctrl, [d[0] * w2, d[1] * w2]);
keep_smooth(kb, Handle::In);
Ok(())
}
/// Every anchor of subpath `s` (⌥-click with Direct Selection selects the whole subpath).
pub fn subpath_anchors(path: &Path, s: usize) -> Vec<[usize; 2]> {
path.subpaths.get(s).map_or_else(Vec::new, |sp| (0..sp.knots.len()).map(|k| [s, k]).collect())
}
/// Anchors inside the box `[x0, y0, x1, y1]` (a Direct Selection marquee), in path order.
pub fn anchors_in(path: &Path, [x0, y0, x1, y1]: [f64; 4]) -> Vec<[usize; 2]> {
let (x0, x1, y0, y1) = (x0.min(x1), x0.max(x1), y0.min(y1), y0.max(y1));
let mut out = Vec::new();
for (s, sp) in path.subpaths.iter().enumerate() {
for (k, kn) in sp.knots.iter().enumerate() {
if (x0..=x1).contains(&kn.anchor.x) && (y0..=y1).contains(&kn.anchor.y) {
out.push([s, k]);
}
}
}
out
}
fn eval(seg: &[Point; 4], t: f64) -> Point {
let u = 1.0 - t;
let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
Point::new(a * seg[0].x + b * seg[1].x + c * seg[2].x + d * seg[3].x, a * seg[0].y + b * seg[1].y + c * seg[2].y + d * seg[3].y)
}
/// The part of `path` within `tol` of `p`: one of the `handles` shown (they sit on top), else an
/// anchor, else a segment; the nearest wins within each kind.
pub fn hit(path: &Path, p: Point, tol: f64, handles: &[([usize; 2], Handle)]) -> Option<Hit> {
let nearest = |it: &mut dyn Iterator<Item = (f64, Hit)>| it.filter(|(d, _)| *d <= tol).min_by(|a, b| a.0.total_cmp(&b.0)).map(|(_, h)| h);
let knot = |r: [usize; 2]| path.subpaths.get(r[0]).and_then(|sp| sp.knots.get(r[1]));
let on_handle = nearest(&mut handles.iter().filter_map(|&(r, h)| {
let k = knot(r)?;
Some((dist(handle(k, h), p), Hit::Handle(r, h)))
}));
if on_handle.is_some() {
return on_handle;
}
let anchors =
path.subpaths.iter().enumerate().flat_map(|(s, sp)| sp.knots.iter().enumerate().map(move |(k, kn)| (dist(kn.anchor, p), Hit::Anchor([s, k]))));
if let Some(h) = nearest(&mut anchors.into_iter()) {
return Some(h);
}
// Segments: the nearest of 64 samples, refined once around it (plenty at screen tolerances).
const N: usize = 64;
let mut segs = path.subpaths.iter().enumerate().flat_map(|(s, sp)| {
sp.segments().into_iter().enumerate().map(move |(k, seg)| {
let best = (0..=N).map(|i| i as f64 / N as f64).map(|t| (dist(eval(&seg, t), p), t)).min_by(|a, b| a.0.total_cmp(&b.0)).unwrap_or((f64::MAX, 0.5));
let step = 1.0 / N as f64;
let fine = (0..=16)
.map(|i| (best.1 - step + 2.0 * step * i as f64 / 16.0).clamp(0.0, 1.0))
.map(|t| (dist(eval(&seg, t), p), t))
.fold(best, |a, b| if b.0 < a.0 { b } else { a });
(fine.0, Hit::Segment([s, k], fine.1))
})
});
nearest(&mut segs)
}
#[cfg(test)]
mod tests {
use super::*;
use photocraft_doc::Subpath;
fn tri() -> Path {
Path::new(vec![Subpath::polygon(&[(0.0, 0.0), (100.0, 0.0), (50.0, 80.0)])])
}
/// A smooth knot at (50, 0) between corners, handles 20 px either side.
fn curve() -> Path {
let mut sp = Subpath::polyline(&[(0.0, 50.0), (50.0, 0.0), (100.0, 50.0)]);
sp.knots[1] = Knot::smooth(Point::new(50.0, 0.0), Point::new(30.0, 0.0), Point::new(70.0, 0.0));
Path::new(vec![sp])
}
fn at(path: &Path, s: usize, k: usize) -> Knot {
path.subpaths[s].knots[k]
}
#[test]
fn anchors_move_with_their_handles_once_each() {
let mut p = curve();
move_anchors(&mut p, &[[0, 1], [0, 1]], [5.0, -2.0]).unwrap();
let k = at(&p, 0, 1);
assert_eq!((k.anchor, k.in_ctrl, k.out_ctrl), (Point::new(55.0, -2.0), Point::new(35.0, -2.0), Point::new(75.0, -2.0)));
assert_eq!(at(&p, 0, 0).anchor, Point::new(0.0, 50.0), "other anchors stay");
}
#[test]
fn bad_indices_are_errors_and_change_nothing() {
let mut p = tri();
assert!(move_anchors(&mut p, &[[0, 0], [0, 9]], [1.0, 1.0]).is_err());
assert_eq!(p, tri(), "validated before any knot moved");
assert!(move_handle(&mut p, [3, 0], Handle::In, Point::new(0.0, 0.0), false).is_err());
assert!(bend_segment(&mut p, [0, 7], 0.5, [1.0, 1.0]).is_err());
assert!(convert_point(&mut p, [usize::MAX, usize::MAX], None).is_err());
let mut open = curve();
assert!(bend_segment(&mut open, [0, 2], 0.5, [1.0, 1.0]).is_err(), "an open subpath's last knot starts no segment");
assert!(bend_segment(&mut open, [0, usize::MAX], 0.5, [1.0, 1.0]).is_err());
}
#[test]
fn smooth_handles_stay_collinear_and_keep_their_length() {
let mut p = curve();
move_handle(&mut p, [0, 1], Handle::Out, Point::new(50.0, 30.0), false).unwrap();
let k = at(&p, 0, 1);
assert_eq!(k.out_ctrl, Point::new(50.0, 30.0));
assert!(dist(k.in_ctrl, Point::new(50.0, -20.0)) < 1e-9, "opposite turned, length 20 kept: {:?}", k.in_ctrl);
assert!(k.smooth);
}
#[test]
fn independent_handles_break_the_knot() {
let mut p = curve();
move_handle(&mut p, [0, 1], Handle::In, Point::new(40.0, 20.0), true).unwrap();
let k = at(&p, 0, 1);
assert_eq!((k.in_ctrl, k.out_ctrl, k.smooth), (Point::new(40.0, 20.0), Point::new(70.0, 0.0), false));
}
#[test]
fn convert_point_retracts_or_pulls_mirrored_handles() {
let mut p = curve();
convert_point(&mut p, [0, 1], None).unwrap();
assert_eq!(at(&p, 0, 1), Knot::corner(50.0, 0.0));
convert_point(&mut p, [0, 0], Some(Point::new(10.0, 60.0))).unwrap();
let k = at(&p, 0, 0);
assert_eq!((k.in_ctrl, k.out_ctrl, k.smooth), (Point::new(-10.0, 40.0), Point::new(10.0, 60.0), true));
}
#[test]
fn straight_segments_move_with_both_anchors() {
let mut p = tri();
// The closing segment (2 → 0) wraps.
bend_segment(&mut p, [0, 2], 0.5, [0.0, 10.0]).unwrap();
assert_eq!((at(&p, 0, 2).anchor, at(&p, 0, 0).anchor, at(&p, 0, 1).anchor), (Point::new(50.0, 90.0), Point::new(0.0, 10.0), Point::new(100.0, 0.0)));
}
#[test]
fn curves_bend_through_the_dragged_point() {
for t in [0.2, 0.5, 0.8] {
let mut p = curve();
let seg = |p: &Path| p.subpaths[0].segments()[1];
let before = eval(&seg(&p), t);
bend_segment(&mut p, [0, 1], t, [3.0, 12.0]).unwrap();
let after = eval(&seg(&p), t);
assert!(dist(after, Point::new(before.x + 3.0, before.y + 12.0)) < 1e-9, "t={t}: {after:?}");
let k = at(&p, 0, 1);
let (vi, vo) = ((k.in_ctrl.x - 50.0, k.in_ctrl.y), (k.out_ctrl.x - 50.0, k.out_ctrl.y));
assert!((vi.0 * vo.1 - vi.1 * vo.0).abs() < 1e-9 && vi.0 * vo.0 + vi.1 * vo.1 < 0.0, "the smooth knot stayed smooth");
}
}
#[test]
fn hits_prefer_handles_then_anchors_then_segments() {
let p = curve();
let shown = [([0, 1], Handle::Out)];
assert_eq!(hit(&p, Point::new(71.0, 1.0), 3.0, &shown), Some(Hit::Handle([0, 1], Handle::Out)));
assert_eq!(hit(&p, Point::new(71.0, 1.0), 3.0, &[]), None, "hidden handles aren't hit");
assert_eq!(hit(&p, Point::new(49.0, 1.0), 3.0, &shown), Some(Hit::Anchor([0, 1])));
let Some(Hit::Segment(r, t)) = hit(&tri(), Point::new(50.0, 1.0), 3.0, &[]) else { panic!("segment") };
assert_eq!(r, [0, 0]);
assert!((t - 0.5).abs() < 0.02, "{t}");
assert_eq!(hit(&tri(), Point::new(50.0, 40.0), 3.0, &[]), None);
}
#[test]
fn marquee_and_subpath_selections() {
let p = tri();
assert_eq!(anchors_in(&p, [110.0, -5.0, 40.0, 5.0]), vec![[0, 1]]);
assert_eq!(subpath_anchors(&p, 0), vec![[0, 0], [0, 1], [0, 2]]);
assert!(subpath_anchors(&p, 4).is_empty());
}
}

View File

@ -4,6 +4,7 @@
//! shape layers (fill + stroke), vector masks, and the inverse direction (tracing a coverage
//! mask back into a path for "Make Work Path").
//!
//! * [`edit`]: point-level path editing (Direct Selection, Convert Point) and hit testing.
//! * [`flatten`]: cubic Bézier flattening with a distance tolerance.
//! * [`raster`]: exact area-coverage scanline rasterizer (non-zero / even-odd per component,
//! boolean combination of components, inversion).
@ -17,6 +18,7 @@
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod edit;
pub mod flatten;
pub mod raster;
pub mod shapes;

View File

@ -70,7 +70,7 @@ Confidence: moderate — the next users of 0.2.x will move these numbers either
| Smart filters / text / effect shapes in PSDs | Measured on our Photoshop-authored set (https://github.com/storytold/photocraft-corpus, `corpus/photoshop`, 258 files): see the per-group floors in `crates/io/tests/corpus.rs` and `crates/engine/tests/photoshop_oracles.rs`. Smart objects and smart filters now survive PSD save and open (41 corpus files, 206 smart objects, round trip strict; Photoshop opens our exports with live filters); re-rendering Photoshop's smart filters with ours matches 5/30 (was 1/30) | low–medium | Remaining re-render gaps are filter maths (Gaussian/Motion Blur, Unsharp Mask, Emboss, Add Noise RNG) and bicubic placement. |
| Core editing (layers, masks, selections, adjustments, filters, transforms) | Broad engine coverage; many interaction bugs fixed after 0.2.0 (adjustment dialogs, Curves, crop, Move/Transform modifiers, gesture origin) | medium | Fixes not yet user-validated. |
| UI / UX polish | Shortcut audit 214 → 0 failures; dock, Layers rows and menus reworked; first visual-QA sweep found 14 defects (#147–#157). 2026-10-07: the keyboard-only shortcuts with no menu item (⌥[ ⌥] ⌥, ⌥. layer navigation, ⇧⌥[ ⇧⌥] to extend the selection, 1–0 for opacity and ⇧ for flow or fill, ⇧[ ⇧] hardness, ⌥⌘T to transform a copy and ⌥⇧⌘T to step and repeat), ⌥-click colour sampling with painting tools, double-click a Layers row for Layer Style, File › New from Clipboard, a centred main window and remembered Liquify settings (#352, #417, #350, #368, #419, #418) | low–medium | Needs recurring visual QA with realistic documents. |
| Tools | ~20 Photoshop tools missing: Pencil, Mixer Brush (tool), Patch, Content-Aware Move, Red Eye, Pattern Stamp, Art History Brush, Freeform/Curvature Pen, anchor tools, Direct Selection, single row/column marquee, Color Sampler, Perspective Crop, Rotate View, the Vertical Type tool (vertical layout itself landed, #199: toggle via Type › Orientation) and type masks, Frame | low–medium | Magic/Background Eraser added; live gradients in progress (#180). Magnetic Lasso added 2026-10-08 (live-wire edge tracing, Width/Contrast/Frequency, `select.magneticLasso`). |
| Tools | ~20 Photoshop tools missing: Pencil, Mixer Brush (tool), Patch, Content-Aware Move, Red Eye, Pattern Stamp, Art History Brush, Freeform/Curvature Pen, Add/Delete Anchor Point tools, single row/column marquee, Color Sampler, Perspective Crop, Rotate View, the Vertical Type tool (vertical layout itself landed, #199: toggle via Type › Orientation) and type masks, Frame | low–medium | Magic/Background Eraser added; live gradients in progress (#180). Magnetic Lasso added 2026-10-08 (live-wire edge tracing, Width/Contrast/Frequency, `select.magneticLasso`). |
| Painting | Brush model and Brush Settings panel near Photoshop; .abr/.grd import; persistent presets; pen pressure/tilt on Windows, web, macOS and X11 | medium | Wayland pen input open (#79); macOS/X11 pressure not yet verified on tablet hardware. |
| Text / typography | Engine works; caret placement and size editing fixed; OpenType features, text-on-path editing, composer parity partial | medium-low | Measure with the Photoshop-authored set. |
| Colour management | Colour-managed canvas (document → monitor), embedded CMYK profiles, linear EXR/HDR, 16-bit float canvas | medium-high | Monitor profile follows only at launch. |
@ -102,7 +102,7 @@ unchanged.
effects, composite with masks and adjustment layers, CMYK print prep…) scripted end to end and
checked against Photoshop's output on every build. Their pass rate becomes the headline parity
number.
4. **Missing tools,** starting with the Pen variants and Direct Selection, Patch / Content-Aware
4. **Missing tools,** starting with the Pen variants (Direct Selection landed, #790), Patch / Content-Aware
Move, Pencil, Rotate View, Perspective Crop.
5. **Complex-document performance** (#125/#128) and GPU tiling beyond the texture limit (#49).
6. **Recurring visual QA** (`cargo run -p photocraft-engine --example designer_psd`) and fast

View File

@ -8,7 +8,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
|---|---:|---:|---:|---|
| [Performance](#performance) | 3 | 22 | 13 | 47 scenarios: 3 within budget, 22 over budget or failing, 15 tracked only, 6 not run, 7 not measurable yet (baseline 2026-10-05) |
| [File compatibility](#file-compatibility) | 3 | 9 | 15 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 229/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 132/256, round trip ≥ 258/256 |
| [Tools](#tools) | 6 | 14 | 10 | |
| [Tools](#tools) | 6 | 15 | 9 | |
| [Workspace and UI](#workspace-and-ui) | 0 | 5 | 18 | settings that do nothing: 57 of 135 (target 0) |
| [Type](#type) | 0 | 3 | 6 | |
| [Automation](#automation) | 3 | 2 | 6 | 626 of 626 menu items live (100.0%) |
@ -144,7 +144,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
| TOOL-214-7 | Content-Aware Scale through the Free Transform box with a live proxy preview | partial | [#214](https://github.com/storytold/photocraft/issues/214) | edit.contentAwareScale (edit_menu_cmds.rs:899) behind a parameter dialog |
| TOOL-214-8 | A measured ruler angle prefills Image Rotation > Arbitrary (0.01 deg) | missing | [#214](https://github.com/storytold/photocraft/issues/214) | Arbitrary rotation is a generic dialog; ruler only feeds straighten |
| TOOL-214-9 | Crop: W x H x Resolution with units, saved crop presets, Straighten in the options bar | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Ratio dropdown and fields only (panels.rs:551-579) |
| TOOL-214-10 | Pen: Alt breaks a handle, Shift 45 deg, Ctrl direct-selects, Backspace, close-drag handle | missing | [#214](https://github.com/storytold/photocraft/issues/214) | pen_down gets no modifiers (canvas.rs:1632) |
| TOOL-214-10 | Pen: Alt breaks a handle, Shift 45 deg, Ctrl direct-selects, Backspace, close-drag handle | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Ctrl/Cmd = Direct Selection and Alt = Convert Point on existing paths (direct_select.rs, #790); no Shift 45 deg, Backspace or Alt while drawing |
| TOOL-214-11 | Stroke Path dialog: pick any brush-based tool, Simulate Pressure | partial | [#214](https://github.com/storytold/photocraft/issues/214) | path.stroke brush, pencil, eraser (vector_cmds.rs:1170); brush-only button, no dialog |
| TOOL-214-12 | Shapes: on-canvas corner radius handles, shape feather, selection to shape with tolerance | partial | [#214](https://github.com/storytold/photocraft/issues/214) | select.convertToShape with tolerance (vector_cmds.rs:1256); radius as a field only |
| TOOL-214-13 | Mask Properties: Density and Feather controls | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Vector-mask density/feather in the engine (vector_cmds.rs:948); no UI controls |

View File

@ -169,9 +169,9 @@ note = "Ratio dropdown and fields only (panels.rs:551-579)"
[[item]]
id = "TOOL-214-10"
target = "Pen: Alt breaks a handle, Shift 45 deg, Ctrl direct-selects, Backspace, close-drag handle"
status = "missing"
status = "partial"
issue = 214
note = "pen_down gets no modifiers (canvas.rs:1632)"
note = "Ctrl/Cmd = Direct Selection and Alt = Convert Point on existing paths (direct_select.rs, #790); no Shift 45 deg, Backspace or Alt while drawing"
[[item]]
id = "TOOL-214-11"