* 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:
parent
ec7302f4b3
commit
75ff41a26b
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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:?}");
|
||||
}
|
||||
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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")),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]],
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue