diff --git a/ROADMAP.md b/ROADMAP.md index ca3bd5cbb6..1d1174c504 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~30 points (ready for real work ~45% → ~75%, with reliable PSD) and ~1,100–1,800 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-15 · **Change:** minor (Russian catalog covers every dynamic label; previously minor: oracle floor 145 of 258 and 953 open issues, as in gaps.md and on GitHub; toolbox count 56 of 68, 12 missing) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (Object Finder retains large enclosed foregrounds (#6548); Russian catalog covers every dynamic label; previously minor: oracle floor 145 of 258 and 953 open issues, as in gaps.md and on GitHub; toolbox count 56 of 68, 12 missing) · **Target:** Adobe Photoshop 2026 PhotoCraft's one-page summary of how close it is to Adobe Photoshop 2026 (27.11.0, measured from the installed app on 2026-10-10) at version 0.6.0. Detail: [target-app parity](docs/target-app-parity.md) @@ -122,6 +122,7 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md). | Date | Entry | |---|---| +| 2026-10-17 | Object Finder classifies background by boundary coverage, retaining large enclosed foregrounds and rejecting surrounding backdrop (#6548); learned segmentation remains open (#2788) | | 2026-10-10 | Full re-measure against Photoshop 2026 27.11.0 (installed bundle inspected): breadth ~76%, ready ~45%, alpha. Progress docs reorganized to the craftrules standard (`parity.md` → `parity-checklist.md`, `parity-estimate.md` merged into `target-app-parity.md`). 0.6.0 released. | | 2026-10-09 | Remove tool, Rotate View, crop rotation; OpenRaster and Paint.NET; 39 Affinity documents in the corpus | | 2026-10-08 | 0.5.0. Affinity import; Magnetic Lasso, Pattern Stamp, Mixer Brush, Pencil, Patch, Vertical Type on the toolbar | @@ -135,6 +136,7 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Object Finder retains large enclosed foregrounds (#6548) | | 2026-10-13 | minor | Focus item 1 quoted the oracle floor as 133 of 256; it is 145 of 258 (`crates/io/tests/corpus.rs`, gaps.md G1). Open issues 579 → 953 (2026-10-13) | | 2026-10-15 | minor | Russian catalog covers every dynamic UI label the other catalogs translate | | 2026-10-11 | minor | Toolbox count 56 of 68 (12 missing) with the Convert Point tool (#3204) | diff --git a/crates/algo/src/segment/grabcut.rs b/crates/algo/src/segment/grabcut.rs index e1c1d89de0..95ed668e68 100644 --- a/crates/algo/src/segment/grabcut.rs +++ b/crates/algo/src/segment/grabcut.rs @@ -141,11 +141,15 @@ pub const OBJECT_AT_WORK_PX: usize = 60_000; /// A heuristic, not a semantic segmenter (that is #2788): on a working-resolution copy of the /// canvas the area around the point is grown over pixels of similar colour (CIELAB, bounded both /// by the distance to the seed's running mean and by the local edge step), so it stops at the -/// object's outline. A region that grows over most of the canvas is the background: nothing is -/// found. Otherwise GrabCut refines it inside its padded bounding box (the grown area probable +/// object's outline. A region covering most of the canvas boundary is treated as background. +/// Otherwise GrabCut refines it inside its padded bounding box (the grown area probable /// foreground, the cursor definite foreground, outside the box definite background), the part /// connected to the cursor is kept and the boundary is re-cut at full resolution, as the /// rectangle mode does. +/// +/// When the whole-canvas pass finds nothing in a large document (an object only a few working +/// pixels wide blurs into its surroundings), the search is repeated at finer steps in windows of +/// the same working size around the point, down to full resolution. pub fn object_at(sampler: &dyn Sampler, canvas: Rect, x: f32, y: f32, work_px: usize) -> Option { if canvas.width() < 4 || canvas.height() < 4 || !x.is_finite() || !y.is_finite() { return None; @@ -154,7 +158,30 @@ pub fn object_at(sampler: &dyn Sampler, canvas: Rect, x: f32, y: f32, work_px: u if !canvas.contains(px, py) { return None; } - let step = super::scale_for(canvas, work_px.max(1024)); + let work_px = work_px.max(1024); + let mut step = super::scale_for(canvas, work_px); + if let Some(r) = object_in(sampler, canvas, px, py, step) { + return Some(r); + } + // Side (working pixels) of the square windows of the finer passes. + let side = (work_px as f64).sqrt() as usize; + while step > 1 { + step = (step / 4).max(1); + let half = (side.saturating_mul(step) / 2).min(i32::MAX as usize / 4) as i32; + let window = Rect::new(px.saturating_sub(half), py.saturating_sub(half), px.saturating_add(half), py.saturating_add(half)).intersect(&canvas); + if window.width() < 4 || window.height() < 4 { + continue; + } + if let Some(r) = object_in(sampler, window, px, py, step) { + return Some(r); + } + } + None +} + +/// One [`object_at`] pass over `canvas` (the document or a window of it) at working step `step`; +/// `(px, py)` lies inside `canvas`. +fn object_in(sampler: &dyn Sampler, canvas: Rect, px: i32, py: i32, step: usize) -> Option { let img = sampler.rgb_scaled(canvas, step); let (w, h) = (img.w, img.h); if w < 2 || h < 2 { @@ -166,8 +193,9 @@ pub fn object_at(sampler: &dyn Sampler, canvas: Rect, x: f32, y: f32, work_px: u let sy = (((py - canvas.y0) as usize) / step).min(h - 1); let grown = grow_similar(&lab, w, h, sx, sy); let count = grown.iter().filter(|g| **g).count(); - // Background (or a flat image): no single object under the cursor. - if count * 10 > w * h * 6 || count < 4 { + let boundary = grown.iter().enumerate().filter(|(i, on)| **on && (i % w == 0 || i % w + 1 == w || i / w == 0 || i / w + 1 == h)).count(); + // shortcut: cropped objects covering most of the frame need a semantic segmenter (#2788). + if boundary * 2 >= 2 * w + 2 * h - 4 || count < 4 { return None; } let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0usize, 0usize); diff --git a/crates/algo/src/segment/tests.rs b/crates/algo/src/segment/tests.rs index e060913c9b..165272231b 100644 --- a/crates/algo/src/segment/tests.rs +++ b/crates/algo/src/segment/tests.rs @@ -751,6 +751,38 @@ fn object_at_finds_the_disc_under_the_cursor() { assert!(v > 0.9, "IoU {v}"); } +#[test] +fn object_at_keeps_large_foregrounds_but_not_the_surrounding_background() { + let (w, h) = (400, 300); + for margin in [30, 20, 8] { + let inside = |x: usize, y: usize| x >= margin && x < w - margin && y >= margin && y < h - margin; + let img = RgbImage::from_fn(w, h, |x, y| { + let noise = ((x * 13 + y * 7) % 17) as f32 / 850.0; + if inside(x, y) { [0.8 + noise, 0.2, 0.1] } else { [0.1, 0.2, 0.8 + noise] } + }); + let truth: Vec<_> = (0..w * h).map(|i| inside(i % w, i / w)).collect(); + let sampler = ImageSampler { img: &img, origin: (-40, 25) }; + let canvas = Rect::new(-40, 25, w as i32 - 40, h as i32 + 25); + let region = grabcut::object_at(&sampler, canvas, 160.0, 175.0, grabcut::OBJECT_AT_WORK_PX) + .unwrap_or_else(|| panic!("missing large foreground with margin {margin}")); + let local = Region { bbox: region.bbox.translate(40, -25), mask: region.mask }; + assert!(iou(&local, &truth, w, h) > 0.95, "margin {margin}"); + assert!(grabcut::object_at(&sampler, canvas, -39.0, 26.0, grabcut::OBJECT_AT_WORK_PX).is_none(), "background margin {margin}"); + } +} + +#[test] +fn object_at_allows_a_foreground_touching_one_edge_but_rejects_a_flat_image() { + let img = RgbImage::from_fn(400, 300, |x, y| if x < 360 && (20..280).contains(&y) { [0.8, 0.2, 0.1] } else { [0.1, 0.2, 0.8] }); + let sampler = ImageSampler { img: &img, origin: (0, 0) }; + let canvas = Rect::new(0, 0, 400, 300); + let region = grabcut::object_at(&sampler, canvas, 200.0, 150.0, grabcut::OBJECT_AT_WORK_PX).unwrap(); + assert!(region.at(0, 150) > 0.9 && region.at(350, 150) > 0.9); + assert!(region.at(380, 150) < 0.1 && region.at(200, 5) < 0.1); + let flat = RgbImage::from_fn(400, 300, |_, _| [0.5; 3]); + assert!(grabcut::object_at(&ImageSampler { img: &flat, origin: (0, 0) }, canvas, 200.0, 150.0, grabcut::OBJECT_AT_WORK_PX).is_none()); +} + #[test] fn object_at_on_the_background_or_off_canvas_finds_nothing() { let (img, _) = disc_scene(320, 240, 160.0, 120.0, 70.0, 7); @@ -762,6 +794,28 @@ fn object_at_on_the_background_or_off_canvas_finds_nothing() { assert!(grabcut::object_at(&s, canvas, 400.0, 10.0, 0).is_none()); } +/// #6576: a 24 px object in a 4000×3000 document is found (a 1000×800 one still is). +#[test] +fn object_at_finds_small_object_in_large_canvas() { + let (w, h) = (4000usize, 3000usize); + let canvas = Rect::new(0, 0, w as i32, h as i32); + for (ox, oy, ow, oh) in [(188usize, 138usize, 24usize, 24usize), (1500, 1100, 1000, 800)] { + let inside = |x: usize, y: usize| x >= ox && x < ox + ow && y >= oy && y < oy + oh; + let img = RgbImage::from_fn(w, h, |x, y| if inside(x, y) { [0.8, 0.1, 0.1] } else { [0.9, 0.9, 0.9] }); + let truth: Vec = (0..w * h).map(|i| inside(i % w, i / w)).collect(); + let s = ImageSampler { img: &img, origin: (0, 0) }; + let (cx, cy) = ((ox + ow / 2) as f32, (oy + oh / 2) as f32); + let reg = grabcut::object_at(&s, canvas, cx, cy, grabcut::OBJECT_AT_WORK_PX); + let reg = reg.unwrap_or_else(|| panic!("the {ow}×{oh} object is not found")); + let v = iou(®, &truth, w, h); + assert!(v > 0.8, "{ow}×{oh}: IoU {v}"); + } + // The flat background around the small object is still nothing. + let img = RgbImage::from_fn(w, h, |x, y| if (188..212).contains(&x) && (138..162).contains(&y) { [0.8, 0.1, 0.1] } else { [0.9, 0.9, 0.9] }); + let s = ImageSampler { img: &img, origin: (0, 0) }; + assert!(grabcut::object_at(&s, canvas, 400.0, 300.0, grabcut::OBJECT_AT_WORK_PX).is_none()); +} + // ---------- Select Subject on the cases reported in #2788 ---------- /// A figure whose upper part is close to the background colour (a cream shirt on a near-white diff --git a/crates/algo/src/segment/workflow_tests.rs b/crates/algo/src/segment/workflow_tests.rs index ca3be5b40f..96c6caeb8f 100644 --- a/crates/algo/src/segment/workflow_tests.rs +++ b/crates/algo/src/segment/workflow_tests.rs @@ -1,4 +1,28 @@ use super::*; + +#[test] +#[ignore = "release Object Finder timing on a 24 MP document"] +#[allow(clippy::assertions_on_constants)] +fn object_finder_large_foreground_release_timing() { + assert!(!cfg!(debug_assertions), "run with --release"); + let (w, h) = (6000, 4000); + let img = RgbImage::from_fn(w, h, |x, y| if (300..5700).contains(&x) && (200..3800).contains(&y) { [0.8, 0.2, 0.1] } else { [0.1, 0.2, 0.8] }); + let sampler = ImageSampler { img: &img, origin: (0, 0) }; + let canvas = Rect::new(0, 0, w as i32, h as i32); + for (name, x, y) in [("foreground", 3000.0, 2000.0), ("background", 10.0, 10.0)] { + let start = std::time::Instant::now(); + let region = grabcut::object_at(&sampler, canvas, x, y, grabcut::OBJECT_AT_WORK_PX); + println!("OBJECT_FINDER {w}x{h} {name}: {:.1} ms", start.elapsed().as_secs_f64() * 1000.0); + if name == "foreground" { + let region = region.unwrap(); + assert!(region.at(3000, 2000) > 0.9 && region.at(310, 210) > 0.9); + assert!(region.at(10, 10) < 0.1); + } else { + assert!(region.is_none()); + } + } +} + #[test] #[ignore = "release comparison of actual Quick Selection strokes on 24-36 MP documents"] #[allow(clippy::assertions_on_constants)] diff --git a/crates/doc/src/vector.rs b/crates/doc/src/vector.rs index 4a216f904d..a66aed8587 100644 --- a/crates/doc/src/vector.rs +++ b/crates/doc/src/vector.rs @@ -303,10 +303,14 @@ pub enum LiveShape { rect: [f64; 4], }, /// Regular polygon or star inscribed in `rect`; `star_ratio` = inner/outer radius (1 = polygon). + /// `corner_radius` rounds every vertex (0 = sharp), each within what its edges allow; the + /// Triangle tool draws a 3-sided one. Polygon { rect: [f64; 4], sides: u32, star_ratio: f64, + #[serde(default)] + corner_radius: f64, }, /// Line from `from` to `to` drawn as a filled bar `weight` px thick. Line { @@ -372,6 +376,15 @@ mod tests { assert_eq!(t.control_bounds(), Some((2.0, 3.0, 6.0, 10.0))); } + #[test] + fn a_polygon_saved_before_corner_radii_loads_sharp() { + let old = r#"{"kind":"polygon","rect":[0.0,0.0,10.0,10.0],"sides":3,"star_ratio":1.0}"#; + let back: LiveShape = serde_json::from_str(old).unwrap(); + assert_eq!(back, LiveShape::Polygon { rect: [0.0, 0.0, 10.0, 10.0], sides: 3, star_ratio: 1.0, corner_radius: 0.0 }); + let round = LiveShape::Polygon { rect: [0.0; 4], sides: 6, star_ratio: 1.0, corner_radius: 4.5 }; + assert_eq!(serde_json::from_str::(&serde_json::to_string(&round).unwrap()).unwrap(), round); + } + #[test] fn serde_roundtrip() { let p = Path::new(vec![Subpath::polygon(&[(0.0, 0.0), (3.0, 4.0)]).with_op(PathOp::Exclude)]); diff --git a/crates/engine/src/cutout_cmds.rs b/crates/engine/src/cutout_cmds.rs index 2f6e2b70b5..7e4fafbbe5 100644 --- a/crates/engine/src/cutout_cmds.rs +++ b/crates/engine/src/cutout_cmds.rs @@ -6,6 +6,7 @@ use photocraft_algo::matting::{self, RefineParams}; use photocraft_algo::segment::subject; use photocraft_doc::{Document, Layer, LayerContent, LayerMask}; +use photocraft_geom::Rect; use serde_json::{Value, json}; use crate::commands::{CommandSpec, layer_param}; @@ -35,21 +36,32 @@ fn enabled(s: &Session) -> std::result::Result<(), String> { check(&d.doc, crate::active_layer_of(s)?) } +/// Where the subject is looked for: the layer's own pixels within the canvas. A photo that +/// doesn't fill the canvas (placed, or the canvas grown) would otherwise stand out from the +/// empty canvas around it as the subject, and the mask would reveal its whole rectangle, which a +/// Stroke then outlines (#8235). The whole canvas when sampling all layers. +fn subject_area(l: &Layer, canvas: Rect, sample_all: bool) -> Rect { + let Some(s) = l.surface().filter(|_| !sample_all) else { return canvas }; + let empty_around = s.format().alpha && s.default_pixel().last().is_some_and(|a| *a <= 0.0); + let b = s.content_bounds().intersect(&canvas); + if empty_around && !b.is_empty() { b } else { canvas } +} + fn run(s: &mut Session, p: &Value) -> Result { let id = layer_param(s, p)?; let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; - check(doc, doc.layer(id).ok_or(EngineError::NoLayer(id))?).map_err(EngineError::Other)?; + let layer = doc.layer(id).ok_or(EngineError::NoLayer(id))?; + check(doc, layer).map_err(EngineError::Other)?; let sample_all = p.get("sampleAllLayers").and_then(Value::as_bool).unwrap_or(false); let refine = p.get("refine").and_then(Value::as_bool).unwrap_or(true); + let area = subject_area(layer, doc.bounds(), sample_all); crate::jobs::edit_job( s, "Remove Background", move |doc, _, ctx| { // Cancel reaches the min cuts inside Select Subject, so Esc doesn't wait for them. let found = ctx - .stage(0.1, 0.6, "Finding subject", |ctl| { - with_doc_sampler(doc, Some(id), sample_all, |smp, d| subject::select_subject_with(smp, d.bounds(), ctl)) - }) + .stage(0.1, 0.6, "Finding subject", |ctl| with_doc_sampler(doc, Some(id), sample_all, |smp, _| subject::select_subject_with(smp, area, ctl))) .map_err(|_| EngineError::Cancelled)?; let mut region = found.ok_or_else(|| EngineError::Other("no subject found".into()))?; if ctx.cancelled() { @@ -57,8 +69,8 @@ fn run(s: &mut Session, p: &Value) -> Result { } if refine { ctx.progress(0.6, "Refining edge"); - let refined = with_doc_sampler(doc, Some(id), sample_all, |smp, d| { - matting::refine_mask(smp, &matting::region_reader(®ion), region.bbox, d.bounds(), &REFINE) + let refined = with_doc_sampler(doc, Some(id), sample_all, |smp, _| { + matting::refine_mask(smp, &matting::region_reader(®ion), region.bbox, area, &REFINE) }); region = refined.unwrap_or(region); if ctx.cancelled() { @@ -94,7 +106,6 @@ mod tests { use super::*; use photocraft_algo::segment::Rng; use photocraft_doc::LayerId; - use photocraft_geom::Rect; fn session_with(w: u32, h: u32, depth: u32, px: impl Fn(u32, u32) -> [f32; 3]) -> Session { let mut s = Session::new(); @@ -287,4 +298,42 @@ mod tests { let masks = s.active().unwrap().doc.walk().into_iter().filter(|(_, _, l)| l.mask.is_some()).count(); assert_eq!(masks, 1); } + + #[test] + fn remove_background_on_a_layer_smaller_than_the_canvas_masks_the_subject_not_the_layer() { + // The photo doesn't fill the canvas (a placed photo, or the canvas grown): the empty + // canvas around it is not the background, and a Stroke must not outline the photo's + // rectangle (#8235). + for depth in [8, 16, 32] { + let (mut s, inside) = disc(depth); + s.execute("layer.new.layerFromBackground", json!({})).unwrap(); + s.execute("image.canvasSize", json!({"width": 260, "height": 220, "anchor": "center"})).unwrap(); + let id = active_id(&s); + s.execute(CMD, json!({})).unwrap(); + // The photo now sits at (30, 30). + let v = mask_iou(&s, 260, 220, |x, y| inside(x - 30, y - 30)); + assert!(v >= 0.75, "depth {depth}: IoU {v}"); + for (x, y) in [(31, 31), (228, 31), (31, 188), (228, 188), (40, 110)] { + assert!(mask_cov(&s, x, y) < 0.2, "depth {depth}: photo background at ({x}, {y}) revealed"); + } + s.edit("stroke", |doc, _| { + let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + l.effects.items = vec![photocraft_doc::Effect::Stroke(photocraft_doc::StrokeFx { + common: photocraft_doc::FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), + size: 3.0, + position: photocraft_doc::StrokePosition::Outside, + paint: photocraft_doc::FxPaint::Color(photocraft_color::Color::rgb(0.0, 0.0, 1.0)), + overprint: false, + })]; + Ok(()) + }) + .unwrap(); + let img = photocraft_compose::render(&s.active().unwrap().doc, Rect::new(0, 0, 260, 220)); + // Just outside the photo's left edge, and just inside it: no stroke. + for x in [27, 28, 29, 30, 31] { + let a = img.px[110 * 260 + x][3]; + assert!(a < 0.05, "depth {depth}: stroke along the layer's edge at x = {x} (alpha {a})"); + } + } + } } diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 4181451fa0..f14bd978de 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -497,26 +497,7 @@ fn shift_shown(doc: &Document, l: &mut Layer, dx: i32, dy: i32) { *c = translate_surface(c, dx, dy); } } - LayerContent::Shape(sh) => { - // The rendered shape is cut at the canvas: one that reaches past it renders again. - let canvas = doc.bounds(); - let was_inside = sh.cache.as_ref().is_some_and(|c| { - let b = photocraft_compose::bounds::content_bounds(c); - b.is_empty() || (b.x0 > canvas.x0 && b.y0 > canvas.y0 && b.x1 < canvas.x1 && b.y1 < canvas.y1) - }); - crate::vector_cmds::transform_shape(sh, &a); - let stroke_pad = sh.stroke.as_ref().filter(|s| s.opacity > 0.0).map_or(0.0, |s| f64::from(s.width.ceil())); - let stays_inside = sh.path.control_bounds().is_some_and(|(x0, y0, x1, y1)| { - (x0 - stroke_pad) > canvas.x0 as f64 - && (y0 - stroke_pad) > canvas.y0 as f64 - && (x1 + stroke_pad) < canvas.x1 as f64 - && (y1 + stroke_pad) < canvas.y1 as f64 - }); - match &mut sh.cache { - Some(c) if was_inside && stays_inside => *c = translate_surface(c, dx, dy), - _ => crate::vector_cmds::refresh_shape(doc, sh), - } - } + LayerContent::Shape(sh) => crate::vector_cmds::shift_shape(doc, sh, f64::from(dx), f64::from(dy)), LayerContent::Smart(sm) => crate::smart_cmds::shift_smart(sm, dx, dy), LayerContent::Group(g) => { if let Some(ab) = &mut g.artboard { diff --git a/crates/engine/src/presets/gradients.rs b/crates/engine/src/presets/gradients.rs index 4c384ccea7..4105ceb1c0 100644 --- a/crates/engine/src/presets/gradients.rs +++ b/crates/engine/src/presets/gradients.rs @@ -441,15 +441,47 @@ fn active_gradient_fill(s: &Session) -> Option { matches!(d.doc.layer(id)?.content, LayerContent::Fill(Fill::Gradient { .. })).then_some(id) } +fn active_shape(s: &Session) -> Option { + let d = s.active()?; + let id = d.active_layer?; + matches!(d.doc.layer(id)?.content, LayerContent::Shape(_)).then_some(id) +} + fn select(s: &mut Session, p: &Value) -> Result { let g = preset_param(s, p, CMD_SELECT)?; + let apply = p.get("applyToLayer").and_then(Value::as_bool).unwrap_or(true); + let shape = active_shape(s).filter(|_| apply); + // A locked shape refuses before anything changes, the Gradient tool's gradient included. + if let (Some(id), Some(d)) = (shape, s.active()) { + let locks = d.doc.effective_locks(id); + if locks.all || locks.pixels { + return Err(bad(CMD_SELECT, "the selected shape layer is locked")); + } + } s.presets.gradient = g.clone(); s.presets_changed(); let mut out = json!({"current": g.to_json()}); + // Clicking a preset with a shape layer selected fills the shape with it (Photoshop, #5568); + // a gradient fill keeps its angle, style, scale and reverse. + if let Some(id) = shape { + let (stops_new, opacity_new) = g.fill_stops(s.tools.foreground, s.tools.background); + crate::vector_cmds::with_shape(s, id, "Edit Shape", |sh, _| { + let (angle, scale, style, reverse) = match &sh.fill { + Some(Fill::Gradient { angle, scale, style, reverse, .. }) => (*angle, *scale, *style, *reverse), + _ => (90.0, 1.0, photocraft_doc::GradientStyle::Linear, false), + }; + let mut fill = Fill::gradient(stops_new, angle, scale, style, reverse); + if let Fill::Gradient { opacity_stops, .. } = &mut fill { + *opacity_stops = opacity_new; + } + sh.fill = Some(fill); + Ok(()) + })?; + out["layer"] = json!(id.0); + return Ok(out); + } // Clicking a preset with a Gradient Fill layer selected changes that layer (Photoshop). - if p.get("applyToLayer").and_then(Value::as_bool).unwrap_or(true) - && let Some(id) = active_gradient_fill(s) - { + if apply && let Some(id) = active_gradient_fill(s) { let (stops_new, opacity_new) = g.fill_stops(s.tools.foreground, s.tools.background); s.edit("Change Gradient Fill", |doc, _| { let mode = doc.mode; @@ -674,7 +706,7 @@ pub fn specs() -> Vec { menu: &[], shortcut: None, params: leak(format!( - r##"{{"preset":name|{stops},"group":name?,"applyToLayer":bool=true (also recolours a selected Gradient Fill layer)}} → {{current,layer?}}. The Gradient tool paints with it."## + r##"{{"preset":name|{stops},"group":name?,"applyToLayer":bool=true (also recolours a selected Gradient Fill layer, or fills a selected shape layer with it)}} → {{current,layer?}}. The Gradient tool paints with it."## )), enabled: always, run: select, diff --git a/crates/engine/src/presets/tests.rs b/crates/engine/src/presets/tests.rs index 432ef67ca9..3906db577c 100644 --- a/crates/engine/src/presets/tests.rs +++ b/crates/engine/src/presets/tests.rs @@ -766,6 +766,48 @@ fn clone_source_errors_and_overlay() { assert_eq!(v["rotation"], json!(-90.0)); } +fn shape_fill(s: &Session, id: LayerId) -> Option { + match &s.active().unwrap().doc.layer(id).unwrap().content { + LayerContent::Shape(sh) => sh.fill.clone(), + _ => panic!("not a shape layer"), + } +} + +/// #5568: clicking a gradient with a shape layer selected fills the shape with it in one undo +/// step; a gradient fill keeps its angle, style, scale and reverse. +#[test] +fn gradient_preset_click_fills_the_selected_shape_layer() { + let mut s = session(8); + let id = LayerId(s.execute("shape.create", json!({"rect": [8, 8, 40, 30], "fill": "#336699"})).unwrap()["layer"].as_u64().unwrap()); + let before = shape_fill(&s, id); + let r = s.execute("gradient.presets.select", json!({"preset": "Red 01"})).unwrap(); + assert_eq!(r["layer"], json!(id.0)); + let Some(Fill::Gradient { stops, angle, style, .. }) = shape_fill(&s, id) else { panic!("not a gradient") }; + // Red 01 starts at #7f0000; a new gradient fill is linear at 90°. + assert_eq!(stops[0].1.to_rgba8(), [0x7f, 0, 0, 0xff]); + assert_eq!((angle, style), (90.0, photocraft_doc::GradientStyle::Linear)); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(shape_fill(&s, id), before); + + s.execute("shape.edit", json!({"layer": id.0, "fill": {"gradient": {"angle": 30, "style": "radial", "scale": 80, "reverse": true}}})).unwrap(); + s.execute("gradient.presets.select", json!({"preset": "Green 01"})).unwrap(); + let Some(Fill::Gradient { angle, style, scale, reverse, .. }) = shape_fill(&s, id) else { panic!("not a gradient") }; + assert_eq!((angle, style, scale, reverse), (30.0, photocraft_doc::GradientStyle::Radial, 0.8, true)); +} + +/// A locked shape refuses the click before anything changes, the Gradient tool's gradient included. +#[test] +fn gradient_preset_click_on_a_locked_shape_changes_nothing() { + let mut s = session(8); + let id = LayerId(s.execute("shape.create", json!({"rect": [8, 8, 40, 30], "fill": "#336699"})).unwrap()["layer"].as_u64().unwrap()); + let (before, gradient) = (shape_fill(&s, id), s.presets.gradient.name.clone()); + s.execute("layer.lockLayers", json!({"all": true})).unwrap(); + let err = s.execute("gradient.presets.select", json!({"preset": "Red 01"})).unwrap_err().to_string(); + assert!(err.contains("locked"), "{err}"); + assert_eq!(shape_fill(&s, id), before); + assert_eq!(s.presets.gradient.name, gradient); +} + // ------------------------------------------------------------------ persistence /// #5602: one damaged preset set in the preferences file must not discard the saved New diff --git a/crates/engine/src/smartselect_cmds.rs b/crates/engine/src/smartselect_cmds.rs index 380b6a7200..039f248a5b 100644 --- a/crates/engine/src/smartselect_cmds.rs +++ b/crates/engine/src/smartselect_cmds.rs @@ -221,12 +221,20 @@ impl ObjectFinderCache { /// `area`, say so instead of quietly finding nothing (#5246; scripts often add an empty layer /// above the photo first). fn check_sampled_pixels(doc: &Document, layer: Option, all_layers: bool, area: Rect) -> Result<()> { - let Some(surf) = sampled_target(doc, layer, all_layers).and_then(|id| doc.layer(id)).and_then(Layer::surface) else { return Ok(()) }; - if surf.content_bounds().intersect(&area).is_empty() { - let msg = if surf.content_bounds().is_empty() { "the active layer is empty" } else { "the active layer has no pixels in that area" }; - return Err(bad("select.object", &format!("{msg}; set \"sampleAllLayers\": true or select a layer with pixels"))); + match empty_sampled_area(doc, layer, all_layers, area) { + Some(msg) => Err(bad("select.object", &format!("{msg}; set \"sampleAllLayers\": true or select a layer with pixels"))), + None => Ok(()), } - Ok(()) +} + +/// Why sampling `layer` (unless `all_layers`) can find nothing in `area`: the layer is empty, or has no pixels there. +/// `None` = there is something to sample. The click path reports it as an error, the Object Finder hover as a hint. +pub fn empty_sampled_area(doc: &Document, layer: Option, all_layers: bool, area: Rect) -> Option<&'static str> { + let surf = sampled_target(doc, layer, all_layers).and_then(|id| doc.layer(id)).and_then(Layer::surface)?; + if !surf.content_bounds().intersect(&area).is_empty() { + return None; + } + Some(if surf.content_bounds().is_empty() { "the active layer is empty" } else { "the active layer has no pixels in that area" }) } /// `select.object` with `"point"`: the Object Finder's object under the point (cached). @@ -696,6 +704,22 @@ mod tests { assert!(s.execute("select.object", json!({"point": ["a", 1]})).is_err()); } + #[test] + fn object_selection_finds_a_large_foreground_at_each_depth_and_undoes() { + for depth in [8, 16, 32] { + let inside = |x: i32, y: i32| (10..190).contains(&x) && (8..152).contains(&y); + let mut s = session_with(200, 160, depth, |x, y| if inside(x as i32, y as i32) { [0.8, 0.2, 0.1] } else { [0.1, 0.2, 0.8] }); + let r = s.execute("select.object", json!({"point": [100, 80]})).unwrap(); + assert_eq!(r["changed"], true, "depth {depth}"); + assert!(sel_iou(&s, 200, 160, inside) > 0.95, "depth {depth}"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.active().unwrap().doc.selection.is_none()); + let r = s.execute("select.object", json!({"point": [1, 1]})).unwrap(); + assert_eq!(r["changed"], false, "background at depth {depth}"); + assert!(s.active().unwrap().doc.selection.is_none()); + } + } + #[test] fn object_selection_point_honours_the_modes() { let (mut s, _) = disc(8); diff --git a/crates/engine/src/vector_cmds.rs b/crates/engine/src/vector_cmds.rs index 7a871abad9..ffeeac2a26 100644 --- a/crates/engine/src/vector_cmds.rs +++ b/crates/engine/src/vector_cmds.rs @@ -412,13 +412,19 @@ fn live_from_params(cmd: &str, kind: &str, p: &Value, prev: Option<&LiveShape>) } "ellipse" => LiveShape::Ellipse { rect: rect()? }, "polygon" | "star" => { - let (ps, pr) = match prev { - Some(LiveShape::Polygon { sides, star_ratio, .. }) => (*sides, *star_ratio), - _ => (5, if kind == "star" { 0.5 } else { 1.0 }), + let (ps, pr, pc) = match prev { + Some(LiveShape::Polygon { sides, star_ratio, corner_radius, .. }) => (*sides, *star_ratio, *corner_radius), + _ => (5, if kind == "star" { 0.5 } else { 1.0 }, 0.0), }; + let corner_radius = f64p(p, "cornerRadius").map_or(pc, |v| if v.is_finite() { v.clamp(0.0, 1.0e6) } else { pc }); let sides = f64p(p, "sides").map_or(ps, |v| v.round().clamp(3.0, 100.0) as u32); let ratio = f64p(p, "starRatio").map_or(if kind == "star" && pr >= 1.0 { 0.5 } else { pr }, |v| v.clamp(0.01, 1.0)); - LiveShape::Polygon { rect: rect()?, sides, star_ratio: if kind == "polygon" && f64p(p, "starRatio").is_none() { 1.0 } else { ratio } } + LiveShape::Polygon { + rect: rect()?, + sides, + star_ratio: if kind == "polygon" && f64p(p, "starRatio").is_none() { 1.0 } else { ratio }, + corner_radius, + } } "line" => { let (pf, pto, pw) = match prev { @@ -481,10 +487,7 @@ pub fn translate_vectors(doc: &Document, l: &mut Layer, dx: f64, dy: f64) { vm.path = vm.path.transform(&a); } match &mut l.content { - LayerContent::Shape(sh) => { - transform_shape(sh, &a); - refresh_shape(doc, sh); - } + LayerContent::Shape(sh) => shift_shape(doc, sh, dx, dy), LayerContent::Group(g) => { for c in &mut g.children { translate_vectors(doc, c, dx, dy); @@ -494,6 +497,30 @@ pub fn translate_vectors(doc: &Document, l: &mut Layer, dx: f64, dy: f64) { } } +/// Moves a shape by `(dx, dy)`. A whole-pixel move shifts the rendered pixels as they are, so a +/// shape from a PSD keeps Photoshop's anti-aliasing (as moved type does). The render is cut at +/// the canvas, so a shape reaching past it renders again, as does a fractional move. +pub(crate) fn shift_shape(doc: &Document, sh: &mut ShapeLayer, dx: f64, dy: f64) { + transform_shape(sh, &Affine::translate(dx, dy)); + let whole = |v: f64| (v.fract() == 0.0 && v.abs() <= f64::from(i32::MAX)).then_some(v as i32); + let canvas = doc.bounds(); + let inside = sh.cache.as_ref().is_some_and(|c| { + let b = photocraft_compose::bounds::content_bounds(c); + b.is_empty() || (b.x0 > canvas.x0 && b.y0 > canvas.y0 && b.x1 < canvas.x1 && b.y1 < canvas.y1) + }); + let stroke_pad = sh.stroke.as_ref().filter(|s| s.opacity > 0.0).map_or(0.0, |s| f64::from(s.width.ceil())); + let stays_inside = sh.path.control_bounds().is_some_and(|(x0, y0, x1, y1)| { + (x0 - stroke_pad) > canvas.x0 as f64 + && (y0 - stroke_pad) > canvas.y0 as f64 + && (x1 + stroke_pad) < canvas.x1 as f64 + && (y1 + stroke_pad) < canvas.y1 as f64 + }); + match (&mut sh.cache, whole(dx), whole(dy)) { + (Some(c), Some(dx), Some(dy)) if inside && stays_inside => *c = crate::layer_multi_cmds::shift_surface(c, dx, dy), + _ => refresh_shape(doc, sh), + } +} + /// Applies an affine transform to a shape's path, keeping the live shape when the transform /// is a translation plus axis-aligned scale (otherwise the shape stops being parametric). pub fn transform_shape(sh: &mut ShapeLayer, a: &Affine) { @@ -510,7 +537,9 @@ pub fn transform_shape(sh: &mut ShapeLayer, a: &Affine) { LiveShape::Rect { rect: r(rect), radii: radii.map(|v| v * k) } } LiveShape::Ellipse { rect } => LiveShape::Ellipse { rect: r(rect) }, - LiveShape::Polygon { rect, sides, star_ratio } => LiveShape::Polygon { rect: r(rect), sides, star_ratio }, + LiveShape::Polygon { rect, sides, star_ratio, corner_radius } => { + LiveShape::Polygon { rect: r(rect), sides, star_ratio, corner_radius: corner_radius * m0.min(m3) } + } LiveShape::Line { from, to, weight } => { // Live-shape edits regenerate the line's filled bar from its parameters. // Keep the weight in sync with a positive scale of the path, or an @@ -704,7 +733,7 @@ fn shape_edit(s: &mut Session, p: &Value) -> Result { sh.live = None; sh.psd_raw = None; } - let geom_keys = ["kind", "rect", "radii", "radius", "sides", "starRatio", "from", "to", "weight"]; + let geom_keys = ["kind", "rect", "radii", "radius", "sides", "starRatio", "cornerRadius", "from", "to", "weight"]; if geom_keys.iter().any(|k| p.get(*k).is_some()) { let kind = p.get("kind").and_then(Value::as_str).map(str::to_string).or_else(|| sh.live.as_ref().map(|l| live_kind(l).to_string())); let kind = kind.ok_or_else(|| bad(CMD, "this shape has no live-shape parameters; pass `kind` or `path`"))?; @@ -1732,7 +1761,7 @@ pub fn specs() -> Vec { "New Shape Layer", ["Layer", "New"], leak(format!( - r##"{{"kind":"rect|roundedRect|ellipse|polygon|star|line|path"="rect","rect":[x,y,w,h] (rect/roundedRect/ellipse/polygon/star),"radii":[tl,tr,br,bl]|r (roundedRect=10),"sides":3..100=5,"starRatio":0..1 (star=0.5),"from":[x,y],"to":[x,y],"weight":px=1 (line),"path":{{…}} (kind path),{fill}=foreground,{stroke}=none,"name":str?,"addTo":layerId? + "op":"combine|subtract|intersect|exclude" (append to an existing shape layer)}} → shape.info. {PATH_FORM}"## + r##"{{"kind":"rect|roundedRect|ellipse|polygon|star|line|path"="rect","rect":[x,y,w,h] (rect/roundedRect/ellipse/polygon/star),"radii":[tl,tr,br,bl]|r (roundedRect=10),"sides":3..100=5,"starRatio":0..1 (star=0.5),"cornerRadius":px=0 (polygon/star: rounds every vertex; the Triangle tool is a 3-sided polygon),"from":[x,y],"to":[x,y],"weight":px=1 (line),"path":{{…}} (kind path),{fill}=foreground,{stroke}=none,"name":str?,"addTo":layerId? + "op":"combine|subtract|intersect|exclude" (append to an existing shape layer)}} → shape.info. {PATH_FORM}"## )), has_doc, shape_create @@ -1742,7 +1771,7 @@ pub fn specs() -> Vec { "Edit Shape", [], leak(format!( - r##"{{"layer":id?,"path":{{…}}? (replaces; drops live shape),"kind":str?,"rect":[x,y,w,h]?,"radii":[tl,tr,br,bl]|r?,"sides":n?,"starRatio":r?,"from":[x,y]?,"to":[x,y]?,"weight":px? (live-shape params regenerate the path),"move":[dx,dy]?,"transform":[a,b,c,d,e,f]?,"op":"combine|…"? + "subpath":index? (default all but the first),"fillRule":"nonzero|evenodd"?,{fill}?,{stroke}? (merged into the current stroke),"name":str?}} → shape.info"## + r##"{{"layer":id?,"path":{{…}}? (replaces; drops live shape),"kind":str?,"rect":[x,y,w,h]?,"radii":[tl,tr,br,bl]|r?,"sides":n?,"starRatio":r?,"cornerRadius":px?,"from":[x,y]?,"to":[x,y]?,"weight":px? (live-shape params regenerate the path),"move":[dx,dy]?,"transform":[a,b,c,d,e,f]?,"op":"combine|…"? + "subpath":index? (default all but the first),"fillRule":"nonzero|evenodd"?,{fill}?,{stroke}? (merged into the current stroke),"name":str?}} → shape.info"## )), has_layer, shape_edit diff --git a/crates/engine/src/vector_cmds/tests.rs b/crates/engine/src/vector_cmds/tests.rs index 0789e900ad..a204c8f8fc 100644 --- a/crates/engine/src/vector_cmds/tests.rs +++ b/crates/engine/src/vector_cmds/tests.rs @@ -941,6 +941,74 @@ fn shape_info_bounds_of_curved_shapes_are_exact() { } } +/// A shape layer as a PSD import leaves it: Photoshop's rendering as the cache (deliberately not +/// what PhotoCraft would draw, a block of another colour, so a re-render shows). +fn add_imported_shape(s: &mut Session, r: Rect) -> LayerId { + s.edit("shape", |doc, active| { + let fmt = doc.pixel_format(); + let path = photocraft_vector::shapes::rect(f64::from(r.x0), f64::from(r.y0), f64::from(r.width()), f64::from(r.height())); + let mut cache = photocraft_raster::Surface::new(fmt); + cache.fill_rect(r, &photocraft_raster::from_rgba(&fmt, [0.9, 0.5, 0.1, 1.0])); + let sh = ShapeLayer { path, fill: Some(photocraft_doc::Fill::Solid(photocraft_color::Color::BLACK)), cache: Some(cache), ..Default::default() }; + let id = doc.insert_above(None, Layer::new("Rectangle 1", LayerContent::Shape(sh))); + *active = Some(id); + Ok(id) + }) + .unwrap() +} + +fn shape_layer(doc: &Document, id: LayerId) -> ShapeLayer { + match &doc.layer(id).unwrap().content { + LayerContent::Shape(sh) => sh.clone(), + other => panic!("not a shape layer: {}", other.kind_name()), + } +} + +fn orange_at(sh: &ShapeLayer, x: i32, y: i32) -> bool { + let c = sh.cache.as_ref().unwrap(); + let px = photocraft_raster::to_rgba(&c.format(), &c.pixel(x, y)); + (px[0] - 0.9).abs() < 0.01 && (px[1] - 0.5).abs() < 0.01 +} + +#[test] +fn moving_and_exporting_imported_shapes_keeps_their_rendering() { + for depth in [8, 16, 32] { + let mut s = session(100, 80, depth); + let id = add_imported_shape(&mut s, Rect::new(30, 20, 60, 40)); + // The Move tool: the same pixels, moved; the path moves with them. + s.execute("layer.translate", json!({"layer": id.0, "dx": 5, "dy": -3})).unwrap(); + let sh = shape_layer(doc(&s), id); + assert_eq!(sh.cache.as_ref().unwrap().content_bounds(), Rect::new(35, 17, 65, 37), "depth {depth}"); + assert!(orange_at(&sh, 40, 20), "depth {depth}: not re-rendered"); + assert_eq!(sh.path.control_bounds().map(|b| (b.0.round(), b.1.round())), Some((35.0, 17.0)), "the path moves too"); + // Artboards to Files moves the board to the origin the same way. + s.execute("layer.new.artboard", json!({"rect": [20, 10, 60, 40]})).unwrap(); + let board = doc(&s).artboards()[0].0; + s.edit("into board", |doc, _| { + let l = doc.remove(id).unwrap(); + let Some(LayerContent::Group(g)) = doc.layer_mut(board).map(|b| &mut b.content) else { panic!("artboard group") }; + g.children.push(l); + Ok(()) + }) + .unwrap(); + let one = crate::artboard_cmds::artboard_document(doc(&s), board).unwrap().unwrap(); + let sh = shape_layer(&one, id); + assert_eq!(sh.cache.as_ref().unwrap().content_bounds(), Rect::new(15, 7, 45, 27), "depth {depth}"); + assert!(orange_at(&sh, 20, 10), "depth {depth}: exported as rendered"); + } +} + +#[test] +fn a_shape_reaching_past_the_canvas_renders_again_when_moved() { + let mut s = session(100, 80, 8); + // Cut at the canvas's right edge (100 px), so its pixels can't simply be shifted back in. + let id = add_imported_shape(&mut s, Rect::new(80, 20, 120, 40)); + s.execute("layer.translate", json!({"layer": id.0, "dx": -30, "dy": 0})).unwrap(); + let sh = shape_layer(doc(&s), id); + assert!(!orange_at(&sh, 60, 30), "re-rendered from the path (black fill)"); + assert_eq!(sh.cache.as_ref().unwrap().content_bounds(), Rect::new(50, 20, 90, 40), "the whole shape shows"); +} + #[test] fn shape_create_negative_size_rect_covers_the_ellipse_box() { // Issue #4203: a rectangle with a negative width spanned x 50..70 while the ellipse and @@ -1070,3 +1138,35 @@ fn combine_shapes_fails_gracefully() { assert!(s.execute("layer.combineShapes.unite", json!({})).is_err()); assert_eq!(*doc(&s), before); } + +#[test] +fn triangles_and_polygons_take_a_corner_radius_kept_through_edits_and_scaling() { + let mut s = session(200, 200, 8); + let r = s.execute("shape.create", json!({"kind": "polygon", "rect": [20, 20, 120, 120], "sides": 3, "cornerRadius": 12})).unwrap(); + let id = r["layer"].as_u64().unwrap(); + let live = |s: &Session| match &s.active().unwrap().doc.layer(LayerId(id)).unwrap().content { + LayerContent::Shape(sh) => sh.live.clone(), + _ => None, + }; + let path_len = |s: &Session| match &s.active().unwrap().doc.layer(LayerId(id)).unwrap().content { + LayerContent::Shape(sh) => sh.path.subpaths[0].knots.len(), + _ => 0, + }; + assert!(matches!(live(&s), Some(LiveShape::Polygon { sides: 3, corner_radius, .. }) if corner_radius == 12.0)); + assert_eq!(path_len(&s), 6, "every vertex rounded"); + // Other edits keep the radius; a radius edit regenerates the path; 0 makes it sharp again. + s.execute("shape.edit", json!({"layer": id, "sides": 6})).unwrap(); + assert!(matches!(live(&s), Some(LiveShape::Polygon { sides: 6, corner_radius, .. }) if corner_radius == 12.0)); + assert_eq!(path_len(&s), 12); + s.execute("shape.edit", json!({"layer": id, "cornerRadius": 0})).unwrap(); + assert_eq!(path_len(&s), 6); + // Hostile values never panic: negative and non-finite radii are clamped or ignored. + for bad in [json!(-5), json!(1e300), json!("x")] { + let _ = s.execute("shape.edit", json!({"layer": id, "cornerRadius": bad})); + } + assert!(matches!(live(&s), Some(LiveShape::Polygon { corner_radius, .. }) if corner_radius.is_finite() && corner_radius >= 0.0)); + // Scaling the layer by half halves the radius, as for a rectangle's radii. + s.execute("shape.edit", json!({"layer": id, "cornerRadius": 10})).unwrap(); + s.execute("shape.edit", json!({"layer": id, "transform": [0.5, 0, 0, 0.5, 0, 0]})).unwrap(); + assert!(matches!(live(&s), Some(LiveShape::Polygon { corner_radius, .. }) if (corner_radius - 5.0).abs() < 1e-9)); +} diff --git a/crates/io/src/vector_map.rs b/crates/io/src/vector_map.rs index 3bd127445d..bb830dbd68 100644 --- a/crates/io/src/vector_map.rs +++ b/crates/io/src/vector_map.rs @@ -671,7 +671,7 @@ mod tests { let b = vogk_bytes(&s, 72.0).unwrap(); assert_eq!(live_from_vogk(&b), Some(s)); } - assert!(vogk_bytes(&LiveShape::Polygon { rect: [0.0; 4], sides: 5, star_ratio: 1.0 }, 72.0).is_none()); + assert!(vogk_bytes(&LiveShape::Polygon { rect: [0.0; 4], sides: 5, star_ratio: 1.0, corner_radius: 0.0 }, 72.0).is_none()); } #[test] @@ -699,7 +699,7 @@ mod tests { // Edit: path changes → vmsk regenerated, live dropped when polygon. let mut edited = sh.clone(); edited.path.subpaths.pop(); - edited.live = Some(LiveShape::Polygon { rect: [0.0; 4], sides: 3, star_ratio: 1.0 }); + edited.live = Some(LiveShape::Polygon { rect: [0.0; 4], sides: 3, star_ratio: 1.0, corner_radius: 0.0 }); shape_blocks(&edited, &mut raw, 64, 64, 72.0); assert!(!raw.iter().any(|(k, _)| k == b"vogk")); let (p, _) = path_from_vmsk(&raw[0].1, 64, 64).unwrap(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 7ec79cd6ba..a0d7eb068e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -230,28 +230,37 @@ impl Drag { /// style (fixed ratio / fixed size), ⇧ for a square or circle, ⌥ to draw from the centre. pub fn marquee_corners(o: &crate::state::ToolOptions, d: &Drag, end: [f64; 2]) -> ([f64; 2], [f64; 2]) { let (shift, alt) = d.marquee_mods(); + if o.marquee_style == "fixedSize" { + // A fixed box is positioned, not sized, by the drag. Using the press plus the drag's + // sign pinned it there and flipped it by its full width/height when the pointer crossed + // the press. The preview and release both follow the current pointer instead. + let (w, h) = (f64::from(o.marquee_width).max(1.0), f64::from(o.marquee_height).max(1.0)); + let a = if alt { [end[0] - w / 2.0, end[1] - h / 2.0] } else { end }; + return (a, [a[0] + w, a[1] + h]); + } let e = crate::chrome_ui::marquee_end(&o.marquee_style, o.marquee_width as f64, o.marquee_height as f64, shift, d.start, end); if !alt { return (d.start, e); } let (dx, dy) = (e[0] - d.start[0], e[1] - d.start[1]); - // A fixed size is centred on the press point; otherwise the drag is the half extent. - let (hx, hy) = if o.marquee_style == "fixedSize" { (dx / 2.0, dy / 2.0) } else { (dx, dy) }; - ([d.start[0] - hx, d.start[1] - hy], [d.start[0] + hx, d.start[1] + hy]) + // For a sized marquee the drag is the half extent when drawing from the centre. + ([d.start[0] - dx, d.start[1] - dy], [d.start[0] + dx, d.start[1] + dy]) } /// The pixel rectangle a Rectangular/Elliptical Marquee drag selects so far: shown live (ants /// and readout) exactly as the release commits it, on whole pixel edges as in Photoshop. `None` /// while the gesture is still a click (it never left the press point by more than a few screen /// pixels at `zoom`, points per document pixel) or the rectangle has no area: a release then -/// deselects. A one-pixel row or column is a selection (#2391). +/// deselects. A one-pixel row or column is a selection (#2391). With the Fixed Size style a +/// click places the selection, as in Photoshop. pub(crate) fn marquee_preview_px(o: &crate::state::ToolOptions, d: &Drag, zoom: f32) -> Option<[f64; 4]> { - if !matches!(d.tool, Tool::RectMarquee | Tool::EllipseMarquee) || crate::shape_dialog::is_click(d.start, &d.points, zoom) { + let fixed = o.marquee_style == "fixedSize"; + if !matches!(d.tool, Tool::RectMarquee | Tool::EllipseMarquee) || (!fixed && crate::shape_dialog::is_click(d.start, &d.points, zoom)) { return None; } let last = d.points.last().map_or(d.start, |p| [p[0], p[1]]); let (a, b) = marquee_corners(o, d, last); - let mut r = marquee_px(a, b); + let mut r = if fixed { fixed_size_px(a, b, o.marquee_width, o.marquee_height) } else { marquee_px(a, b) }; if marquee_is_square(o, d) { r = square_px(r, d.start, last); } @@ -263,6 +272,16 @@ pub fn marquee_px(a: [f64; 2], b: [f64; 2]) -> [f64; 4] { [a[0].min(b[0]).floor(), a[1].min(b[1]).floor(), a[0].max(b[0]).ceil(), a[1].max(b[1]).ceil()] } +/// The pixel rectangle of a Fixed Size marquee between the corners `a` and `b`: exactly `w` × `h` +/// whole pixels. Snapping both edges outwards (`marquee_px`) added a pixel to each side whenever +/// the press point sat between pixels, so 10 × 10 selected 11 × 11 (#6015). The near edge goes +/// to the closest pixel edge and the far one is the size away. +pub fn fixed_size_px(a: [f64; 2], b: [f64; 2], w: f32, h: f32) -> [f64; 4] { + let (w, h) = (f64::from(w).round().max(1.0), f64::from(h).round().max(1.0)); + let (x0, y0) = (a[0].min(b[0]).round(), a[1].min(b[1]).round()); + [x0, y0, x0 + w, y0 + h] +} + /// Whether the marquee drag `d` is constrained to a square (or circle): ⇧ during the drag with /// the Normal style, or a Fixed Ratio of 1:1. Mirrors the branches of `chrome_ui::marquee_end`. fn marquee_is_square(o: &crate::state::ToolOptions, d: &Drag) -> bool { @@ -3783,9 +3802,10 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: if tool == Tool::Crop && response.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_down()) && crate::crop_ui::press(app) { ctx.request_repaint(); } - // Capture intent at the press, before egui's drag threshold: Type transforms keep - // Command, and marquee add/subtract must not become a live Shift/Alt constraint. - let capture_press = tool.is_type() || matches!(tool, Tool::RectMarquee | Tool::EllipseMarquee | Tool::SingleRowMarquee | Tool::SingleColumnMarquee); + // Capture intent at the press, before egui's drag threshold or click replay on release: + // Type transforms keep Command; selections keep Add/Subtract if Shift/Alt is released. + let capture_press = tool.is_type() + || matches!(tool, Tool::RectMarquee | Tool::EllipseMarquee | Tool::SingleRowMarquee | Tool::SingleColumnMarquee | Tool::ObjectSelection); let tool_press = egui::Id::new(("tool-pointer-press-modifiers", idx)); if capture_press && let Some((p, press_mods)) = ui.input(|i| { @@ -4026,6 +4046,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: { let click_button = if app.secondary_erase { PointerButton::Secondary } else { PointerButton::Primary }; let click_mods = ui.input(|i| pointer_button_modifiers(&i.events, click_button)).unwrap_or(mods); + let click_mods = if tool == Tool::ObjectSelection && click_button == PointerButton::Primary { + ctx.data(|d| d.get_temp(tool_press)).unwrap_or(click_mods) + } else { + click_mods + }; let d = xf.to_doc(p); match tool { Tool::Zoom if !transform_owns_pointer => { @@ -5562,8 +5587,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); - // A live rectangle's corner radius widgets (Path Selection, Rectangle tool). - if crate::corner_radius::pointer(app, ev, mods) { + // A live shape's corner radius widgets (Path Selection, the shape tools, Move with its + // transform controls). Unsnapped, as Free Transform's handles: snapping would pull the press + // off a widget onto a nearby vertex or guide, and the shape tool would draw a new shape. + if crate::corner_radius::pointer(app, raw, mods) { return; } // Direct Selection, and the Pen's ⌘ (Direct Selection) and ⌥ (Convert Point) modes (#790). @@ -6517,6 +6544,19 @@ mod tests { assert_eq!(square_px([10.0, 20.0, 30.0, 40.0], [10.0, 20.0], [30.0, 40.0]), [10.0, 20.0, 30.0, 40.0]); } + /// #6015: a Fixed Size marquee pressed between pixels selected one pixel more on each side. + #[test] + fn fixed_size_px_is_exactly_the_size() { + use super::fixed_size_px; + // Down-right from (10.4, 20.6): 10 × 10 from the closest pixel edges. + assert_eq!(fixed_size_px([10.4, 20.6], [20.4, 30.6], 10.0, 10.0), [10.0, 21.0, 20.0, 31.0]); + // Up-left: the far corner is the low one; still exactly the size. + assert_eq!(fixed_size_px([10.4, 20.6], [-53.6, -11.4], 64.0, 32.0), [-54.0, -11.0, 10.0, 21.0]); + // 1 × 1 is one pixel, and a fractional size rounds to whole pixels. + assert_eq!(fixed_size_px([3.5, 3.5], [4.5, 4.5], 1.0, 1.0), [4.0, 4.0, 5.0, 5.0]); + assert_eq!(fixed_size_px([0.0, 0.0], [2.6, 2.4], 2.6, 2.4), [0.0, 0.0, 3.0, 2.0]); + } + #[test] fn display_image_matches_egui_unmultiplied_conversion() { // Several parallel pieces and a ragged one; translucent, out-of-range and NaN values. diff --git a/crates/ui-egui/src/color_picker_ui.rs b/crates/ui-egui/src/color_picker_ui.rs index fcbc61ee05..4fe463b961 100644 --- a/crates/ui-egui/src/color_picker_ui.rs +++ b/crates/ui-egui/src/color_picker_ui.rs @@ -800,6 +800,7 @@ pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result }, +} + +/// A selected live shape with corners: its layer, its geometry, and its radii (a rectangle's four +/// `[tl, tr, br, bl]`; a polygon's one radius, repeated). +#[derive(Clone, Debug, PartialEq)] pub struct Target { layer: u64, - rect: [f64; 4], + geom: Geom, radii: [f64; 4], } +/// One corner: its vertex, and how far its arc's centre moves per unit of radius (the centre is +/// `vertex + step * r`: `(±1, ±1)` for a rectangle, `bisector / sin(half the angle)` for a +/// polygon vertex). +#[derive(Clone, Copy, Debug, PartialEq)] +struct Corner { + vertex: [f64; 2], + step: [f64; 2], +} + +impl Target { + fn corners(&self) -> usize { + match &self.geom { + Geom::Rect(_) => 4, + Geom::Polygon { vertices, .. } => vertices.len(), + } + } + + fn is_rect(&self) -> bool { + matches!(self.geom, Geom::Rect(_)) + } + + /// Corner `i`, `None` when it is degenerate. + fn corner(&self, i: usize) -> Option { + match &self.geom { + Geom::Rect([x, y, w, h]) => { + let (vertex, d) = match i { + 0 => ([*x, *y], [1.0, 1.0]), + 1 => ([x + w, *y], [-1.0, 1.0]), + 2 => ([x + w, y + h], [-1.0, -1.0]), + _ => ([*x, y + h], [1.0, -1.0]), + }; + Some(Corner { vertex, step: d }) + } + Geom::Polygon { vertices, .. } => { + let f = self.fillet(i, 1.0)?; + let sin = f.half_angle.sin(); + (sin > 1e-9).then(|| Corner { vertex: vertices.get(i).copied().unwrap_or(f.centre), step: [f.bisector[0] / sin, f.bisector[1] / sin] }) + } + } + } + + /// A polygon vertex's rounding with radius `r`. + fn fillet(&self, i: usize, r: f64) -> Option { + let Geom::Polygon { vertices, .. } = &self.geom else { return None }; + let n = vertices.len(); + let at = |k: usize| vertices.get(k % n).copied(); + shapes::fillet(at(i + n - 1)?, at(i)?, at(i + 1)?, r) + } + + /// The largest radius every corner of the shape can take. + fn max_radius(&self) -> f64 { + match &self.geom { + Geom::Rect(r) => r[2].min(r[3]) / 2.0, + Geom::Polygon { vertices, .. } => (0..vertices.len()).filter_map(|i| self.fillet(i, 1.0e12)).map(|f| f.radius).fold(f64::INFINITY, f64::min), + } + } + + /// The radius corner `i` is drawn with for `radii`: capped at what the shape allows there. + fn radius_at(&self, radii: [f64; 4], i: usize) -> f64 { + match &self.geom { + Geom::Rect(_) => radii.get(i).copied().unwrap_or(0.0).clamp(0.0, self.max_radius()), + Geom::Polygon { .. } => self.fillet(i, radii[0]).map_or(0.0, |f| f.radius), + } + } + + /// Where corner `i`'s widget is drawn for `radii`: the centre of its arc, or a few screen + /// points inside a sharper corner. + fn widget(&self, radii: [f64; 4], i: usize, zoom: f64) -> Option<[f64; 2]> { + let c = self.corner(i)?; + let most = match &self.geom { + Geom::Rect(_) => self.max_radius(), + Geom::Polygon { .. } => self.fillet(i, 1.0e12).map_or(0.0, |f| f.radius), + }; + // The inset is measured like a rectangle's: 10 points along each axis of a right angle. + let len = c.step[0].hypot(c.step[1]).max(1e-9); + let inset = MIN_INSET / zoom * std::f64::consts::SQRT_2 / len; + let r = self.radius_at(radii, i).max(inset).min(most); + Some([c.vertex[0] + c.step[0] * r, c.vertex[1] + c.step[1] * r]) + } + + /// The outline for `radii`. + fn path(&self, radii: [f64; 4]) -> photocraft_doc::Path { + match &self.geom { + Geom::Rect([x, y, w, h]) => shapes::rounded_rect(*x, *y, *w, *h, radii), + Geom::Polygon { rect, sides, star_ratio, .. } => { + shapes::live_path(&LiveShape::Polygon { rect: *rect, sides: *sides, star_ratio: *star_ratio, corner_radius: radii[0] }) + } + } + } +} + /// A corner radius drag in progress. #[derive(Clone, Debug, PartialEq)] pub struct Drag { - /// Every selected live rectangle, as at the press. + /// Every selected live shape with corners, as at the press. targets: Vec, - /// The rectangle whose widget was pressed (an index into `targets`) and its corner, - /// `[tl, tr, br, bl]` order. + /// The shape whose widget was pressed (an index into `targets`) and its corner. grabbed: usize, corner: usize, - /// Every corner (no ⌥) or the dragged one only. + /// Every corner (no ⌥) or the dragged one only (rectangles). all: bool, /// Where the press was. start: [f64; 2], @@ -47,13 +150,39 @@ pub struct Drag { fn shows(app: &PhotocraftApp) -> bool { app.ui.transform.is_none() && match app.ui.tool { - Tool::PathSelection | Tool::Rectangle => true, + Tool::PathSelection | Tool::Rectangle | Tool::Triangle | Tool::Polygon => true, Tool::Move => app.ui.tool_options.move_show_transform, _ => false, } } -/// The selected layers that are live rectangles, front first. +/// A shape layer's live shape as a target, if it has corners. +fn target(layer: u64, live: &LiveShape) -> Option { + let finite = |v: &[f64]| v.iter().all(|v| v.is_finite()); + let positive = |[x, y, w, h]: [f64; 4]| { + let (x, w) = if w < 0.0 { (x + w, -w) } else { (x, w) }; + let (y, h) = if h < 0.0 { (y + h, -h) } else { (y, h) }; + (w > 0.0 && h > 0.0 && finite(&[x, y, w, h])).then_some([x, y, w, h]) + }; + match *live { + LiveShape::Rect { rect, radii } => { + let rect = positive(rect)?; + finite(&radii).then_some(Target { layer, geom: Geom::Rect(rect), radii }) + } + LiveShape::Polygon { rect, sides, star_ratio, corner_radius } => { + positive(rect)?; + if !finite(&[star_ratio, corner_radius]) { + return None; + } + let sharp = shapes::polygon(rect[0], rect[1], rect[2], rect[3], sides, star_ratio); + let vertices = sharp.subpaths.first()?.knots.iter().map(|k| [k.anchor.x, k.anchor.y]).collect(); + Some(Target { layer, geom: Geom::Polygon { rect, sides, star_ratio, vertices }, radii: [corner_radius.max(0.0); 4] }) + } + LiveShape::Ellipse { .. } | LiveShape::Line { .. } => None, + } +} + +/// The selected layers that are live shapes with corners, front first. fn targets(app: &PhotocraftApp) -> Vec { if !shows(app) { return Vec::new(); @@ -65,10 +194,7 @@ fn targets(app: &PhotocraftApp) -> Vec { .filter_map(|id| { let l = st.doc.layer(id)?; let LayerContent::Shape(sh) = &l.content else { return None }; - let Some(LiveShape::Rect { rect: [x, y, w, h], radii }) = sh.live else { return None }; - let (x, w) = if w < 0.0 { (x + w, -w) } else { (x, w) }; - let (y, h) = if h < 0.0 { (y + h, -h) } else { (y, h) }; - (w > 0.0 && h > 0.0 && [x, y, w, h].iter().chain(&radii).all(|v| v.is_finite())).then_some(Target { layer: l.id.0, rect: [x, y, w, h], radii }) + target(l.id.0, sh.live.as_ref()?) }) .collect(); // `selected_layers` lists bottom to top; the topmost widget wins a press. @@ -76,58 +202,19 @@ fn targets(app: &PhotocraftApp) -> Vec { out } -/// The largest radius a corner of `rect` takes. -fn max_radius(rect: [f64; 4]) -> f64 { - rect[2].min(rect[3]) / 2.0 -} - -/// Corner `i` of `rect` and the unit-step direction pointing inside from it. -fn corner(rect: [f64; 4], i: usize) -> ([f64; 2], [f64; 2]) { - let [x, y, w, h] = rect; - match i { - 0 => ([x, y], [1.0, 1.0]), - 1 => ([x + w, y], [-1.0, 1.0]), - 2 => ([x + w, y + h], [-1.0, -1.0]), - _ => ([x, y + h], [1.0, -1.0]), - } -} - -/// Where corner `i`'s widget is drawn: the centre of its arc, or a few screen points inside a -/// sharper corner. -fn widget(rect: [f64; 4], radii: [f64; 4], i: usize, zoom: f64) -> [f64; 2] { - let (c, d) = corner(rect, i); - let r = radii.get(i).copied().unwrap_or(0.0).max(MIN_INSET / zoom).min(max_radius(rect)); - [c[0] + d[0] * r, c[1] + d[1] * r] -} - -/// The radius the pressed corner `i` asks for when the pointer has moved from `start` to `p`: its -/// radius at the press plus the move projected onto the corner's diagonal (inward rounds more), -/// at least 0. Relative, so a click on a sharp corner's inset widget changes nothing. Each -/// rectangle then caps it at half its own shorter side. -fn radius_after(rect: [f64; 4], i: usize, from: f64, start: [f64; 2], p: [f64; 2]) -> f64 { - let (_, d) = corner(rect, i); - let r = from + ((p[0] - start[0]) * d[0] + (p[1] - start[1]) * d[1]) / 2.0; - if r.is_finite() { r.max(0.0) } else { from } -} - fn zoom(app: &PhotocraftApp) -> f64 { f64::from(app.point_zoom().max(0.01)) } -/// The widget of `targets` within grabbing distance of document point `p`: the rectangle (an -/// index into `targets`) and its corner, the nearest when several are close. +/// The widget of `targets` within grabbing distance of document point `p`: the shape (an index +/// into `targets`) and its corner, the nearest when several are close. fn hit(app: &PhotocraftApp, targets: &[Target], p: [f64; 2]) -> Option<(usize, usize)> { let z = zoom(app); let tol = 7.0 / z; targets .iter() .enumerate() - .flat_map(|(t, g)| { - (0..4).map(move |i| { - let w = widget(g.rect, g.radii, i, z); - (t, i, (w[0] - p[0]).hypot(w[1] - p[1])) - }) - }) + .flat_map(|(t, g)| (0..g.corners()).filter_map(move |i| g.widget(g.radii, i, z).map(|w| (t, i, (w[0] - p[0]).hypot(w[1] - p[1]))))) .filter(|(.., d)| *d <= tol) .min_by(|a, b| a.2.total_cmp(&b.2)) .map(|(t, i, _)| (t, i)) @@ -159,12 +246,18 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool ToolEvent::Move { x, y, .. } => { let Some(d) = app.ui.corner_radius.as_mut() else { return false }; let Some(g) = d.targets.get(d.grabbed) else { return true }; - let from = g.radii.get(d.corner).copied().unwrap_or(0.0).clamp(0.0, max_radius(g.rect)); - let r = radius_after(g.rect, d.corner, from, d.start, [x, y]); - // Every selected rectangle takes the same radius, within its own size. + let Some(c) = g.corner(d.corner) else { return true }; + // The radius at the press plus the move projected onto the corner's step (the way its + // arc's centre moves as the radius grows). Relative, so a click on a sharp corner's + // inset widget changes nothing. + let from = g.radius_at(g.radii, d.corner); + let len2 = c.step[0] * c.step[0] + c.step[1] * c.step[1]; + let r = from + ((x - d.start[0]) * c.step[0] + (y - d.start[1]) * c.step[1]) / len2.max(1e-12); + let r = if r.is_finite() { r.max(0.0) } else { from }; + // Every selected shape takes the same radius, within its own size. for (t, now) in d.targets.iter().zip(d.now.iter_mut()) { - let r = r.min(max_radius(t.rect)); - if d.all { + let r = r.min(t.max_radius()); + if d.all || !t.is_rect() { *now = [r; 4]; } else { *now = t.radii; @@ -184,9 +277,15 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool // One history step for every layer changed. let key = format!("corner-radius-{}", app.ui.alloc_id()); for (t, now) in d.targets.iter().zip(&d.now) { - if *now != t.radii - && let Err(e) = app.run("shape.edit", serde_json::json!({"layer": t.layer, "radii": now, "coalesce": key})) - { + if *now == t.radii { + continue; + } + let params = if t.is_rect() { + serde_json::json!({"layer": t.layer, "radii": now, "coalesce": key}) + } else { + serde_json::json!({"layer": t.layer, "cornerRadius": now[0], "coalesce": key}) + }; + if let Err(e) = app.run("shape.edit", params) { app.ui.status = e; app.ui.status_error = true; break; @@ -199,11 +298,13 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool /// The radius the dragged corner shows in the readout. fn dragged_radius(d: &Drag) -> Option { - d.now.get(d.grabbed).and_then(|r| r.get(d.corner)).copied() + let g = d.targets.get(d.grabbed)?; + let now = d.now.get(d.grabbed)?; + if g.is_rect() { now.get(d.corner).copied() } else { Some(g.radius_at(*now, d.corner)) } } -/// The widgets of every selected live rectangle; while one is dragged, the new outlines and the -/// radius beside the pointer (`R: 25.0 px`), as in Photoshop. +/// The widgets of every selected live shape with corners; while one is dragged, the new outlines +/// and the radius beside the pointer (`R: 25.0 px`), as in Photoshop. pub fn draw(app: &PhotocraftApp, painter: &egui::Painter, to_scr: &dyn Fn([f64; 2]) -> Pos2, accent: Color32) { let drag = app.ui.corner_radius.as_ref(); if let Some(r) = drag.and_then(dragged_radius) @@ -216,13 +317,14 @@ pub fn draw(app: &PhotocraftApp, painter: &egui::Painter, to_scr: &dyn Fn([f64; let now = drag.and_then(|d| d.targets.iter().position(|g| g.layer == t.layer).and_then(|i| d.now.get(i))); let radii = now.copied().unwrap_or(t.radii); if now.is_some() { - let path = photocraft_vector::shapes::rounded_rect(t.rect[0], t.rect[1], t.rect[2], t.rect[3], radii); - crate::vector_ui::draw_outline(painter, &path, to_scr, accent); + crate::vector_ui::draw_outline(painter, &t.path(radii), to_scr, accent); } - for i in 0..4 { - let p = to_scr(widget(t.rect, radii, i, z)); - painter.circle(p, 5.0, Color32::WHITE, Stroke::new(1.5, accent)); - painter.circle_filled(p, 2.5, accent); + for i in 0..t.corners() { + if let Some(w) = t.widget(radii, i, z) { + let p = to_scr(w); + painter.circle(p, 5.0, Color32::WHITE, Stroke::new(1.5, accent)); + painter.circle_filled(p, 2.5, accent); + } } } } diff --git a/crates/ui-egui/src/corner_radius_tests.rs b/crates/ui-egui/src/corner_radius_tests.rs index b0b47c45be..252e9ce587 100644 --- a/crates/ui-egui/src/corner_radius_tests.rs +++ b/crates/ui-egui/src/corner_radius_tests.rs @@ -184,3 +184,86 @@ fn the_cursor_and_readout_follow_the_widgets() { tool_event(&mut app, ToolEvent::Up { x: 62.0, y: 62.0 }, Modifiers::NONE); assert!(!super::hovers(&app, [100.0, 80.0])); } + +/// The live polygon of layer `id`: its corner radius. +fn corner_radius_of(app: &PhotocraftApp, id: photocraft_doc::LayerId) -> f64 { + match &app.session.active().unwrap().doc.layer(id).unwrap().content { + LayerContent::Shape(sh) => match sh.live { + Some(LiveShape::Polygon { corner_radius, .. }) => corner_radius, + _ => panic!("live polygon"), + }, + _ => panic!("shape"), + } +} + +/// A live triangle (a 3-sided polygon) in (40, 40) 120 × 120, as the Triangle tool draws it. +fn triangle(tool: Tool, radius: f64) -> (PhotocraftApp, photocraft_doc::LayerId) { + 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.run("shape.create", json!({"kind": "polygon", "rect": [40, 40, 120, 120], "sides": 3, "cornerRadius": radius})).unwrap(); + app.ui.tool = tool; + let id = app.session.active().unwrap().active_layer.unwrap(); + (app, id) +} + +/// Where the widget of corner `i` of the first target is, and the way its arc's centre moves. +fn widget_of(app: &PhotocraftApp, i: usize) -> ([f64; 2], [f64; 2]) { + let t = &super::targets(app)[0]; + let z = f64::from(app.point_zoom()); + (t.widget(t.radii, i, z).unwrap(), t.corner(i).unwrap().step) +} + +#[test] +fn a_triangle_rounds_all_its_corners_from_any_widget_in_one_step() { + for tool in [Tool::Triangle, Tool::Polygon, Tool::PathSelection] { + let (mut app, id) = triangle(tool, 6.0); + assert_eq!(super::targets(&app)[0].corners(), 3, "{tool:?}"); + let n = steps(&app); + // Drag the bottom-right widget inwards by 4 radius units along its step: 6 + 4 = 10. + let (w, step) = widget_of(&app, 1); + let to = [w[0] + step[0] * 4.0, w[1] + step[1] * 4.0]; + drag(&mut app, w, to, Modifiers::NONE); + assert!((corner_radius_of(&app, id) - 10.0).abs() < 1e-9, "{tool:?}: {}", corner_radius_of(&app, id)); + assert_eq!(steps(&app), n + 1, "one history step"); + // ⌥ makes no difference: a triangle has one radius for all its corners. + let (w, step) = widget_of(&app, 0); + drag(&mut app, w, [w[0] + step[0] * 2.0, w[1] + step[1] * 2.0], Modifiers::ALT); + assert!((corner_radius_of(&app, id) - 12.0).abs() < 1e-9); + assert!(app.session.undo()); + assert!((corner_radius_of(&app, id) - 10.0).abs() < 1e-9); + } +} + +#[test] +fn a_triangle_radius_is_capped_and_a_click_on_a_sharp_corner_changes_nothing() { + let (mut app, id) = triangle(Tool::PathSelection, 0.0); + let n = steps(&app); + let (w, step) = widget_of(&app, 2); + drag(&mut app, w, w, Modifiers::NONE); + assert_eq!((corner_radius_of(&app, id), steps(&app)), (0.0, n), "a click commits nothing"); + // Dragged far in: the radius stops where the arcs meet halfway along the edges. + drag(&mut app, w, [w[0] + step[0] * 1000.0, w[1] + step[1] * 1000.0], Modifiers::NONE); + let max = super::targets(&app)[0].max_radius(); + assert!(max > 0.0 && (corner_radius_of(&app, id) - max).abs() < 1e-9, "{} vs {max}", corner_radius_of(&app, id)); +} + +#[test] +fn rectangles_and_triangles_selected_together_take_the_same_radius() { + let (mut app, tri) = triangle(Tool::PathSelection, 0.0); + app.run("shape.create", json!({"kind": "rect", "rect": [10, 170, 100, 20], "radii": 0})).unwrap(); + let rect = app.session.active().unwrap().active_layer.unwrap(); + app.run("layer.select", json!({"layer": tri.0, "mode": "add"})).unwrap(); + // The rectangle's top-left widget (10 px inside, at (20, 180)), 4 px in each direction: 4. + drag(&mut app, [20.0, 180.0], [24.0, 184.0], Modifiers::NONE); + assert_eq!(radii(&app), None, "the active layer is the triangle"); + let rr = match &app.session.active().unwrap().doc.layer(rect).unwrap().content { + LayerContent::Shape(sh) => match sh.live { + Some(LiveShape::Rect { radii, .. }) => radii, + _ => panic!(), + }, + _ => panic!(), + }; + assert_eq!(rr, [4.0; 4]); + assert!((corner_radius_of(&app, tri) - 4.0).abs() < 1e-9); +} diff --git a/crates/ui-egui/src/direct_select.rs b/crates/ui-egui/src/direct_select.rs index b27d9ea466..71fccc3870 100644 --- a/crates/ui-egui/src/direct_select.rs +++ b/crates/ui-egui/src/direct_select.rs @@ -174,7 +174,14 @@ pub(crate) fn active_paths(app: &PhotocraftApp) -> Vec<(PathRef, Path)> { /// The paths Direct Selection edits, front first: the Pen path being drawn, the active paths, then /// every visible shape layer. pub(crate) fn candidates(app: &PhotocraftApp) -> Vec<(PathRef, Path)> { - let pen = app.ui.pen.as_ref().filter(|p| !p.dragging && !p.knots.is_empty()).map(|p| (PathRef::Pen, crate::vector_ui::pen_path(p))); + let active = app.session.active().map(|st| st.doc.id); + let pen = app + .ui + .pen + .as_ref() + .filter(|_| app.ui.pen_doc.is_none_or(|owner| Some(owner) == active)) + .filter(|p| !p.dragging && !p.knots.is_empty()) + .map(|p| (PathRef::Pen, crate::vector_ui::pen_path(p))); let mut out: Vec<_> = pen.into_iter().chain(active_paths(app)).collect(); if let Some(st) = app.session.active() { for (path, _, l) in st.doc.walk().iter().rev() { diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 0ed539ff28..ca27a4683c 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2914,6 +2914,7 @@ cameraData Lens Objektiv Duplicate Layer Comp Duplikovat kompozici vrstev Duplicate Layers Duplikovat vrstvy Duplicate Path Duplikovat cestu + Rename Path… Přejmenovat cestu… Duplication Duplikace Duration Doba trvání Dust & Scratches Prach a škrábance @@ -3774,6 +3775,7 @@ navigator Zoom Zvětšení Medium Blue Středně modrá Object Finder Hledání objektů No object found there — drag a rectangle around it Nebyl tam nalezen žádný objekt — přetáhněte kolem něj obdélník + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Aktivní vrstva tu nemá žádné pixely — zapněte Vzorkovat všechny vrstvy nebo vyberte vrstvu s pixely Remove after each stroke Odstranit po každém tahu Apply the removal ({key}) Použít odstranění ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Tahy k odstranění: {n} (Enter použije, Esc zruší) diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index ef46d206be..6ec8f87926 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2252,6 +2252,7 @@ Fill Path Pfad füllen Stroke Path Pfad nachziehen Delete Path Pfad löschen + Rename Path… Pfad umbenennen… W B H H Ruler Lineal @@ -3170,6 +3171,7 @@ curves Smooth Glätten Fuzziness Toleranz Object Finder Objektsucher No object found there — drag a rectangle around it Dort wurde kein Objekt gefunden — ziehen Sie ein Rechteck darum + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Die aktive Ebene hat hier keine Pixel — aktivieren Sie „Alle Ebenen einbeziehen“ oder wählen Sie eine Ebene mit Pixeln Remove after each stroke Nach jedem Strich entfernen Apply the removal ({key}) Entfernen anwenden ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Striche zum Entfernen: {n} (Eingabe wendet an, Esc bricht ab) diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index dbde5991fd..47ea05dacd 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2361,6 +2361,7 @@ Drag to move the active shape's path or the Work Path Σύρετε για μετακίνηση της διαδρομής του ενεργού σχήματος ή της διαδρομής εργασίας Dual Brush Διπλό πινέλο Duplicate Path Διπλασιασμός διαδρομής + Rename Path… Μετονομασία διαδρομής… Duplicate style Διπλασιασμός στυλ Dust & Scratches Σκόνη και γρατζουνιές Dust & Scratches… Σκόνη και γρατζουνιές… @@ -3861,6 +3862,7 @@ curves Smooth Εξομάλυνση Light Gray Ανοιχτό γκρι Object Finder Εύρεση αντικειμένων No object found there — drag a rectangle around it Δεν βρέθηκε αντικείμενο εκεί — σύρετε ένα ορθογώνιο γύρω του + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Το ενεργό επίπεδο δεν έχει εικονοστοιχεία εδώ — ενεργοποιήστε τη Δειγματοληψία όλων των επιπέδων ή επιλέξτε ένα επίπεδο με εικονοστοιχεία Remove after each stroke Αφαίρεση μετά από κάθε πινελιά Apply the removal ({key}) Εφαρμογή της αφαίρεσης ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Πινελιές προς αφαίρεση: {n} (Enter εφαρμόζει, Esc ακυρώνει) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 9ecae1e3de..7623c54090 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2852,6 +2852,7 @@ curves Smooth Suavizar Light Gray Gris claro Object Finder Buscador de objetos No object found there — drag a rectangle around it No se encontró ningún objeto ahí; arrastra un rectángulo a su alrededor + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels La capa activa no tiene píxeles aquí; activa Muestrear todas las capas o selecciona una capa con píxeles Remove after each stroke Eliminar después de cada trazo Apply the removal ({key}) Aplicar la eliminación ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Trazos por eliminar: {n} (Intro aplica, Esc cancela) @@ -2987,6 +2988,7 @@ curves Smooth Suavizar Clipping Path Trazado de recorte New Path Nuevo trazado Duplicate Path Duplicar trazado + Rename Path… Cambiar nombre del trazado… Auto-Hide Ocultar automáticamente X X Y Y diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index d30ec76a3c..e275829a7e 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2372,6 +2372,7 @@ Clipping Path Tracé de détourage New Path Nouveau tracé Duplicate Path Dupliquer le tracé + Rename Path… Renommer le tracé… Delete Path Supprimer le tracé Auto-Hide Masquage automatique W L @@ -3169,6 +3170,7 @@ curves Smooth Lisser Light Gray Gris clair Object Finder Outil de recherche d’objets No object found there — drag a rectangle around it Aucun objet trouvé ici — tracez un rectangle autour + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Le calque actif n’a pas de pixels ici — activez Échantillonner tous les calques ou sélectionnez un calque contenant des pixels Remove after each stroke Supprimer après chaque trait Apply the removal ({key}) Appliquer la suppression ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Traits à supprimer : {n} (Entrée applique, Échap annule) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 547fe82c15..7bc4be24b9 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3207,6 +3207,7 @@ cameraRaw Yellow Sárga Drop an image or PSD anywhere to open it. Húzz ide bárhová egy képet vagy PSD-fájlt a megnyitásához. Duplicate Layer Comp Rétegkompozíció kettőzése Duplicate Path Útvonal kettőzése + Rename Path… Útvonal átnevezése… Duplication Kettőzés East asian Kelet-ázsiai Edge Extension Szélek kiterjesztése @@ -4084,6 +4085,7 @@ cameraRaw Yellow Sárga @plural {n} action|{n} actions {n} művelet Object Finder Objektumkereső No object found there — drag a rectangle around it Ott nem található objektum – húzz köré egy téglalapot + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Az aktív rétegnek itt nincsenek képpontjai – kapcsold be a Mintavétel minden rétegből beállítást, vagy válassz képpontokat tartalmazó réteget Drag to pan · Scroll to zoom · Double-click to fit Húzás: mozgatás · Görgetés: nagyítás · Dupla kattintás: igazítás Gradient Preset Options Színátmenet-előbeállítás beállításai Import .GRD .GRD importálása diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 5398e20147..4faddfa115 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2379,6 +2379,7 @@ Clipping Path Clipping Path New Path Path Baru Duplicate Path Duplikat Path + Rename Path… Ganti Nama Path… Delete Path Hapus Path Auto-Hide Sembunyi Otomatis W W @@ -3172,6 +3173,7 @@ curves Smooth Haluskan Light Gray Abu-abu Muda Object Finder Pencari Objek No object found there — drag a rectangle around it Tidak ada objek ditemukan di sana — seret persegi panjang di sekelilingnya + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Layer aktif tidak memiliki piksel di sini — aktifkan Sampel Semua Layer atau pilih layer yang berisi piksel Remove after each stroke Hapus setelah setiap goresan Apply the removal ({key}) Terapkan penghapusan ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Goresan untuk dihapus: {n} (Enter menerapkan, Esc membatalkan) diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 49e314c945..638fcfbb6c 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2978,6 +2978,7 @@ curves Smooth Leviga Clipping Path Tracciato di ritaglio New Path Nuovo tracciato Duplicate Path Duplica tracciato + Rename Path… Rinomina tracciato… Auto-Hide Nascondi automaticamente X X Y Y @@ -4127,6 +4128,7 @@ navigator Zoom Zoom {shortcut} is already in use by {commands} and will be removed from it when you click OK. {shortcut} è già usata da {commands} e verrà rimossa quando fai clic su OK. Object Finder Ricerca oggetti No object found there — drag a rectangle around it Nessun oggetto trovato lì: trascina un rettangolo intorno + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Il livello attivo non ha pixel qui: attiva Campiona tutti i livelli o seleziona un livello con pixel Remove after each stroke Rimuovi dopo ogni tratto Apply the removal ({key}) Applica la rimozione ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Tratti da rimuovere: {n} (Invio applica, Esc annulla) diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index bff891a6d5..941e8f934e 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2885,6 +2885,7 @@ fontWeight Italic 斜体 Duplicate Layer Comp レイヤーカンプを複製 Duplicate Layers レイヤーを複製 Duplicate Path パスを複製 + Rename Path… パス名を変更… Dust & Scratches ダスト&スクラッチ EXIF EXIF Export As 書き出し形式を指定 @@ -3774,6 +3775,7 @@ curves Smooth スムーズ Light Gray ライトグレー Object Finder オブジェクトファインダー No object found there — drag a rectangle around it そこにオブジェクトが見つかりません — 周囲に矩形をドラッグしてください + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels アクティブなレイヤーのここにはピクセルがありません — 「すべてのレイヤーをサンプル」をオンにするか、ピクセルのあるレイヤーを選択してください Remove after each stroke ストロークごとに削除 Apply the removal ({key}) 削除を適用 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 削除するストローク: {n}(Enter で適用、Esc で取り消し) diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 6b1b6a2aaf..4727e10548 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1408,6 +1408,7 @@ Rename 이름 바꾸기 Rename Group… 그룹 이름 바꾸기… Rename Layer… 레이어 이름 바꾸기… + Rename Path… 패스 이름 바꾸기… Rename Tool Preset… 도구 사전 설정 이름 바꾸기… Rename… 이름 바꾸기… Resample 리샘플링 @@ -3363,6 +3364,7 @@ curves Smooth 매끄럽게 Stop 정지 Object Finder 개체 찾기 No object found there — drag a rectangle around it 그곳에서 개체를 찾지 못했습니다 — 주위에 사각형을 드래그하세요 + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels 활성 레이어의 이 위치에는 픽셀이 없습니다 — 모든 레이어 샘플링을 켜거나 픽셀이 있는 레이어를 선택하세요 Remove after each stroke 획마다 제거 Apply the removal ({key}) 제거 적용 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 제거할 획: {n} (Enter 적용, Esc 취소) diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index ca12102cf6..d1e6d21457 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3360,6 +3360,7 @@ cameraRaw Color Kleur Do you want to save you made to “{name}” before quitting? Wil je de wijzigingen in “{name}” opslaan voordat je afsluit? Dodge/Burn Tegenhouden/doordrukken Duplicate Path Pad dupliceren + Rename Path… Pad hernoemen… Dust & Scratches Stof en krassen Dynamics Dynamiek Edit Plane Vlak bewerken @@ -3923,6 +3924,7 @@ curves Smooth Vloeiend maken Light Gray Lichtgrijs Object Finder Objectzoeker No object found there — drag a rectangle around it Daar is geen object gevonden — sleep er een rechthoek omheen + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels De actieve laag heeft hier geen pixels — zet Alle lagen samplen aan of selecteer een laag met pixels Remove after each stroke Verwijderen na elke streek Apply the removal ({key}) Verwijdering toepassen ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Streken om te verwijderen: {n} (Enter past toe, Esc annuleert) diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 164dde659c..5d5f06d515 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2412,6 +2412,7 @@ Clipping Path Ścieżka przycinająca New Path Nowa ścieżka Duplicate Path Duplikuj ścieżkę + Rename Path… Zmień nazwę ścieżki… Delete Path Usuń ścieżkę Auto-Hide Ukrywanie automatyczne W Sz. @@ -3777,6 +3778,7 @@ curves Smooth Wygładź Light Gray Jasnoszary Object Finder Wyszukiwanie obiektów No object found there — drag a rectangle around it Nie znaleziono tam obiektu — przeciągnij wokół niego prostokąt + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Aktywna warstwa nie ma tu pikseli — włącz Próbkuj wszystkie warstwy albo wybierz warstwę z pikselami Remove after each stroke Usuwaj po każdym pociągnięciu Apply the removal ({key}) Zastosuj usuwanie ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Pociągnięcia do usunięcia: {n} (Enter stosuje, Esc anuluje) diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 3454ecd5ee..a305dac5f2 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2367,6 +2367,7 @@ Clipping Path Demarcador de corte New Path Novo demarcador Duplicate Path Duplicar demarcador + Rename Path… Renomear demarcador… Delete Path Excluir demarcador Auto-Hide Ocultar automaticamente W L @@ -3953,6 +3954,7 @@ layerLabel No Color Sem cor Light Gray Cinza-claro Object Finder Localizador de objetos No object found there — drag a rectangle around it Nenhum objeto encontrado ali — arraste um retângulo ao redor dele + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels A camada ativa não tem pixels aqui — ative Usar amostra de todas as camadas ou selecione uma camada com pixels Remove after each stroke Remover após cada traço Apply the removal ({key}) Aplicar a remoção ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Traços a remover: {n} (Enter aplica, Esc cancela) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index cb81e204dd..f616137fae 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1198,6 +1198,7 @@ Clipping Path Обтравочный контур New Path Новый контур Duplicate Path Дублировать контур + Rename Path… Переименовать контур… Delete Path Удалить контур Current Path Текущий контур Vector Mask from Current Path Векторная маска из текущего контура @@ -3115,6 +3116,7 @@ curves Smooth Сгладить Light Gray Светло-серый Object Finder Поиск объектов No object found there — drag a rectangle around it Объект не найден — обведите его прямоугольником + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels В активном слое здесь нет пикселей — включите «По всем слоям» или выберите слой с пикселями Remove after each stroke Удалять после каждого штриха Apply the removal ({key}) Применить удаление ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Штрихов к удалению: {n} (Enter применяет, Esc отменяет) diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index f123d0c654..25b33b8bab 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2353,6 +2353,7 @@ Clipping Path Putanja isecanja New Path Nova putanja Duplicate Path Dupliraj putanju + Rename Path… Preimenuj putanju… Delete Path Obriši putanju Auto-Hide Automatski sakrij W Š @@ -4199,6 +4200,7 @@ shape Appearance Izgled Drag to pan · Scroll to zoom · Double-click to fit Prevucite za pomeranje · Skrolujte za zumiranje · Dvaput kliknite za uklapanje Object Finder Pronalazač objekata No object found there — drag a rectangle around it Tu nije pronađen nijedan objekat — prevucite pravougaonik oko njega + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Aktivni sloj ovde nema piksela — uključite Uzorkuj sve slojeve ili izaberite sloj sa pikselima Clear Brush Controls Obriši kontrole četkice Reset All Locked Settings Vrati sva zaključana podešavanja Copy Texture to Other Tools Kopiraj teksturu u druge alatke diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 2a9bbe09da..32d809a54b 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3339,6 +3339,7 @@ cameraRaw Color Renk Do you want to save you made to “{name}” before quitting? Çıkmadan önce “{name}” belgesinde yaptığınız değişiklikleri kaydetmek istiyor musunuz? Dodge/Burn Soldurma/Yakma Duplicate Path Yolu Çoğalt + Rename Path… Yolu Yeniden Adlandır… Dust & Scratches Toz ve Çizikler Dynamics Dinamik Edit Plane Düzlemi Düzenle @@ -3898,6 +3899,7 @@ cameraData Lens Lens @plural {n} action|{n} actions {n} eylem Object Finder Nesne Bulucu No object found there — drag a rectangle around it Orada nesne bulunamadı — çevresine bir dikdörtgen sürükleyin + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Etkin katmanda burada piksel yok — Tüm Katmanları Örnekle seçeneğini açın veya piksel içeren bir katman seçin Drag to pan · Scroll to zoom · Double-click to fit Kaydırmak için sürükleyin · Yakınlaştırmak için kaydırın · Sığdırmak için çift tıklayın Gradient Preset Options Degrade Ön Ayarı Seçenekleri Import .GRD .GRD İçe Aktar diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index ad53ce16b8..2b74983dd8 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1172,6 +1172,7 @@ Clipping Path Відсічний контур New Path Новий контур Duplicate Path Дублювати контур + Rename Path… Перейменувати контур… Delete Path Видалити контур Current Path Поточний контур Vector Mask from Current Path Векторна маска з поточного контуру @@ -3862,6 +3863,7 @@ curves Smooth Згладити Light Gray Світло-сірий Object Finder Пошук об’єктів No object found there — drag a rectangle around it Там не знайдено об’єкта — обведіть його прямокутником + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels В активному шарі тут немає пікселів — увімкніть «Зразок з усіх шарів» або виберіть шар із пікселями Remove after each stroke Видаляти після кожного штриха Apply the removal ({key}) Застосувати видалення ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Штрихів для видалення: {n} (Enter застосовує, Esc скасовує) diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 581d6700b2..9c35c6f29e 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1273,6 +1273,7 @@ Stroke Path Đi nét đường dẫn Save Path Lưu đường dẫn Duplicate Path Nhân bản đường dẫn + Rename Path… Đổi tên đường dẫn… Delete Path Xóa đường dẫn Delete Vector Mask Xóa mặt nạ vector Draw with the Pen tool (P) or make a work path from a selection. Vẽ bằng công cụ Bút (P) hoặc tạo đường dẫn làm việc từ vùng chọn. @@ -3608,6 +3609,7 @@ curves Smooth Làm mượt Response curve Đường cong phản hồi Object Finder Trình tìm đối tượng No object found there — drag a rectangle around it Không tìm thấy đối tượng ở đó — hãy kéo một hình chữ nhật quanh nó + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Lớp đang hoạt động không có điểm ảnh ở đây — hãy bật Lấy mẫu mọi lớp hoặc chọn một lớp có điểm ảnh Remove after each stroke Xóa sau mỗi nét vẽ Apply the removal ({key}) Áp dụng việc xóa ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Nét vẽ cần xóa: {n} (Enter áp dụng, Esc hủy) diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index eeae5165b9..12e8d3dcde 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1449,6 +1449,7 @@ Rename 重命名 Rename Group… 重命名组… Rename Layer… 重命名图层… + Rename Path… 重命名路径… Rename Tool Preset… 重命名工具预设… Rename… 重命名… Resample 重新采样 @@ -2809,6 +2810,7 @@ curves Smooth 平滑 Stop 停止 Object Finder 对象查找程序 No object found there — drag a rectangle around it 那里未找到对象 — 请在其周围拖动矩形 + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels 当前图层在此处没有像素 — 请打开“对所有图层采样”或选择有像素的图层 Remove after each stroke 每次描边后移除 Apply the removal ({key}) 应用移除 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 待移除的描边:{n}(Enter 应用,Esc 取消) diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 021219d8a2..d68e8954b5 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1430,6 +1430,7 @@ Rename 重新命名 Rename Group… 重新命名群組… Rename Layer… 重新命名圖層… + Rename Path… 重新命名路徑… Rename Tool Preset… 重新命名工具預設集… Rename… 重新命名… Resample 重新取樣 @@ -2773,6 +2774,7 @@ curves Smooth 平滑 Stop 停止 Object Finder 物件尋找器 No object found there — drag a rectangle around it 那裡找不到物件 — 請在其周圍拖曳矩形 + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels 作用中圖層在此處沒有像素 — 請開啟「取樣全部圖層」或選取有像素的圖層 Remove after each stroke 每次描邊後移除 Apply the removal ({key}) 套用移除 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 待移除的描邊:{n}(Enter 套用,Esc 取消) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 36cce97cac..c7123df81d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1261,6 +1261,9 @@ impl PhotocraftApp { } fn run_command(&mut self, id: &str, params: Value) -> Result { + if !vector_ui::cancel_stale(self) { + return Err(self.ui.status.clone()); + } if matches!(id, "paint.setSymmetry" | "paint.symmetryFromPath" | "paint.symmetryDisable") { self.ui.symmetry_transform = None; } @@ -1333,10 +1336,6 @@ impl PhotocraftApp { id, "layer.vectorMask.add" | "layer.vectorMask.fromPath" | "layer.vectorMask.revealAll" | "layer.vectorMask.hideAll" | "layer.vectorMask.currentPath" ) && on_active_layer; - // A draft Pen path belongs to the current document, not the one about to be created. - if id == "file.new" && self.ui.pen.is_some() { - vector_ui::pen_commit(self, false); - } let r = if id == "actions.play" { actions::play(self, ¶ms) } else if photocraft_engine::actions_cmds::shell_view_command(id) { @@ -1425,18 +1424,18 @@ impl PhotocraftApp { self.perf.command_ms = gpu_canvas::now_ms() - t0; match &r { Ok(_) => { + if self.ui.status_error { + self.ui.status.clear(); + self.ui.status_error = false; + } self.sync_views(); let removed = self.session.take_samplers_removed(); - if removed > 0 { + if removed > 0 && !self.ui.status_error { // A canvas-size change (crop, Canvas Size, Image Size, rotation) dropped Color // Sampler points that fell outside the new canvas (#1046). self.ui.status = crate::i18n::trn(crate::i18n::current(), removed as u64, "{n} sample point removed", "{n} sample points removed"); self.ui.status_error = false; } - if self.ui.status_error { - self.ui.status.clear(); - self.ui.status_error = false; - } } Err(e) => { self.ui.status = e.clone(); @@ -1470,6 +1469,8 @@ impl PhotocraftApp { crate::canvas::polygon_cancel_stale(self); crate::crop_ui::cancel_stale(self); crate::perspective_crop_ui::cancel_stale(self); + // A pen path under construction belongs to the document it was started on too. + vector_ui::cancel_stale(self); let ids: Vec = self.session.documents().iter().map(|d| d.doc.id).collect(); // Where the document of view `i` is now. Views not tracked yet keep their index. let now = |i: usize| match self.view_docs.get(i) { @@ -3768,3 +3769,5 @@ mod channel_tint_tests { #[cfg(test)] mod options_hit_area_tests; +#[cfg(test)] +mod select_and_mask_button_tests; diff --git a/crates/ui-egui/src/marquee_tests.rs b/crates/ui-egui/src/marquee_tests.rs index 321a31e73a..0f887bb122 100644 --- a/crates/ui-egui/src/marquee_tests.rs +++ b/crates/ui-egui/src/marquee_tests.rs @@ -13,12 +13,16 @@ use crate::canvas::ViewXform; use crate::state::Tool; fn harness(tool: Tool) -> Harness<'static, PhotocraftApp> { + harness_with_step_dt(tool, 0.25) +} + +fn harness_with_step_dt(tool: Tool, step_dt: f32) -> Harness<'static, PhotocraftApp> { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); app.sync_views(); app.ui.extras.rulers = false; app.ui.tool = tool; - let mut h = Harness::builder().with_size(vec2(1000.0, 700.0)).build_ui_state( + let mut h = Harness::builder().with_size(vec2(1000.0, 700.0)).with_step_dt(step_dt).build_ui_state( |ui, app: &mut PhotocraftApp| { let ctx = ui.ctx().clone(); if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { @@ -147,6 +151,57 @@ fn shift_during_the_drag_draws_a_square_and_the_readout_follows() { assert!(h.query_by_label("W:").is_none() && h.query_by_label_contains(" px").is_none(), "the readout goes away with the drag"); } +#[test] +fn object_selection_keeps_the_mode_from_the_mouse_press() { + for dragged in [false, true] { + for held in [false, true] { + for at_press in [Modifiers::SHIFT, Modifiers::ALT] { + let mut h = harness_with_step_dt(Tool::ObjectSelection, 1.0 / 60.0); + h.state_mut().ui.extras.snap = false; + h.state_mut().run("paint.pencil", json!({"points": [[220, 150]], "size": 60, "color": "#ff0000"})).unwrap(); + let st = h.state().session.active().unwrap(); + let doc = st.doc.clone(); + let target = st.active_layer; + let region = photocraft_engine::smartselect_cmds::find_object(&doc, target, false, 220.0, 150.0).expect("the red disc is detectable"); + h.state_mut().session.object_finder.insert(&doc, target, std::sync::Arc::new(region)); + if at_press.alt { + h.state_mut().run("select.all", json!({})).unwrap(); + } else { + h.state_mut().run("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + } + let start_doc = if dragged { [180.0, 110.0] } else { [220.0, 150.0] }; + mods(&mut h, at_press); + move_to(&mut h, start_doc[0], start_doc[1]); + let start = screen(&h, start_doc[0], start_doc[1]); + button(&mut h, start, true, at_press); + assert!(h.state().drag.is_none(), "the press is still below egui's drag threshold"); + let during = if held { at_press } else { Modifiers::NONE }; + mods(&mut h, during); + let end_doc = if dragged { [260.0, 190.0] } else { start_doc }; + release_at(&mut h, end_doc[0], end_doc[1], during); + let app = h.state(); + assert!(!app.ui.status_error, "{}", app.ui.status); + let (command, params) = app.session.journal.last().expect("the gesture dispatched a command"); + assert_eq!(command, "select.object", "the canvas dispatched Object Selection"); + let expected = if at_press.alt { "subtract" } else { "add" }; + assert_eq!(params["mode"], expected, "dragged={dragged}, held={held}"); + let sel = app.session.active().unwrap().doc.selection.as_ref().expect("the old selection survives outside the object"); + assert!(sel.sample_channel(20, 20, 0) > 0.99, "the gesture must preserve selection outside the object"); + assert_eq!(sel.sample_channel(220, 150, 0) > 0.5, !at_press.alt, "the disc follows the requested mode"); + mods(&mut h, Modifiers::NONE); + move_to(&mut h, 220.0, 150.0); + let next = screen(&h, 220.0, 150.0); + button(&mut h, next, true, Modifiers::NONE); + release_at(&mut h, 220.0, 150.0, Modifiers::NONE); + let (_, params) = h.state().session.journal.last().unwrap(); + assert_eq!(params["mode"], "replace", "the next unmodified click must not reuse the previous mode"); + let sel = h.state().session.active().unwrap().doc.selection.as_ref().unwrap(); + assert!(sel.sample_channel(20, 20, 0) < 0.01 && sel.sample_channel(220, 150, 0) > 0.99); + } + } + } +} + /// #5472: a ⇧ square pressed between pixels read 101 × 102 and selected that, because each axis /// snapped to the pixel grid on its own. The readout and the selection are square, and the /// Elliptical Marquee's circle too. @@ -164,6 +219,107 @@ fn shift_square_pressed_between_pixels_has_equal_sides() { } } +/// #6015: Fixed Size pressed between pixels selected one pixel more on each side (10 × 10 became +/// 11 × 11, 1 × 1 became 2 × 2). The readout and the selection are the entered size, and a click +/// without a drag places it too, as in Photoshop. +#[test] +fn fixed_size_selects_exactly_the_entered_size() { + for tool in [Tool::RectMarquee, Tool::EllipseMarquee] { + for (w, ht) in [(10, 10), (1, 1), (64, 32)] { + let mut h = harness(tool); + let o = &mut h.state_mut().ui.tool_options; + o.marquee_style = "fixedSize".into(); + (o.marquee_width, o.marquee_height) = (w as f32, ht as f32); + press_at(&mut h, 50.4, 60.6, Modifiers::NONE); + assert!(readout(&h, w, ht), "{tool:?} {w} × {ht}: the readout shows the entered size"); + release_at(&mut h, 90.0, 90.0, Modifiers::NONE); + assert_eq!(selection(&h), Rect::new(90, 90, 90 + w, 90 + ht), "{tool:?} {w} × {ht}: dragged to the release point"); + + // A click: the button goes down and up at one point. + h.state_mut().run("select.deselect", json!({})).unwrap(); + let p = screen(&h, 120.6, 100.4); + h.event(egui::Event::PointerMoved(p)); + h.run_steps(1); + button(&mut h, p, true, Modifiers::NONE); + button(&mut h, p, false, Modifiers::NONE); + h.run_steps(2); + assert_eq!(selection(&h), Rect::new(121, 100, 121 + w, 100 + ht), "{tool:?} {w} × {ht}: clicked"); + } + } +} + +/// Fixed Size places a box rather than sizing it: the pointer positions it in every direction, +/// including crossing the press point, without flipping by the full width or height. +#[test] +fn fixed_size_drag_positions_the_box_in_every_direction() { + for tool in [Tool::RectMarquee, Tool::EllipseMarquee] { + for zoom in [1.0, 16.0] { + let mut h = harness(tool); + h.state_mut().ui.extras.snap = false; + let o = &mut h.state_mut().ui.tool_options; + o.marquee_style = "fixedSize".into(); + (o.marquee_width, o.marquee_height) = (11.0, 11.0); + h.state_mut().ui.views[0].zoom = zoom; + h.state_mut().ui.views[0].center = [60.0, 70.0]; + h.run_steps(2); + press_at(&mut h, 50.4, 60.6, Modifiers::NONE); + for (x, y) in [(58.4, 66.6), (49.4, 59.6), (49.4, 66.6), (58.4, 59.6), (59.4, 60.6)] { + move_to(&mut h, x, y); + let app = h.state(); + let r = crate::canvas::marquee_preview_px(&app.ui.tool_options, app.drag.as_ref().unwrap(), zoom).unwrap(); + let (x, y) = (f64::from(x).round(), f64::from(y).round()); + assert_eq!(r, [x, y, x + 11.0, y + 11.0], "{tool:?} at zoom {zoom}: follows the pointer"); + assert!(readout(&h, 11, 11), "the live dimensions stay 11 × 11"); + } + // Commit the release position even if there was no move frame at this point. + let p = screen(&h, 60.4, 61.6); + button(&mut h, p, false, Modifiers::NONE); + h.run_steps(2); + assert_eq!(selection(&h), Rect::new(60, 62, 71, 73), "{tool:?}: release commits the final position"); + } + } +} + +#[test] +fn fixed_size_drag_inside_a_completed_box_still_moves_its_outline() { + for snap in [false, true] { + for zoom in [1.0, 26.730814] { + let mut h = harness(Tool::RectMarquee); + h.state_mut().ui.extras.snap = snap; + let o = &mut h.state_mut().ui.tool_options; + o.marquee_style = "fixedSize".into(); + (o.marquee_width, o.marquee_height) = (11.0, 11.0); + h.state_mut().ui.views[0].zoom = zoom; + h.state_mut().ui.views[0].center = [112.0, 110.0]; + h.run_steps(2); + press_at(&mut h, 100.4, 100.6, Modifiers::NONE); + release_at(&mut h, 108.4, 106.6, Modifiers::NONE); + let r = selection(&h); + let (x, y) = ((r.x0 + r.x1) as f32 / 2.0, (r.y0 + r.y1) as f32 / 2.0); + press_at(&mut h, x, y, Modifiers::NONE); + release_at(&mut h, x + 2.0, y + 1.0, Modifiers::NONE); + assert_eq!(selection(&h), r.translate(2, 1), "zoom {zoom}, snap {snap}"); + } + } +} + +#[test] +fn fixed_size_alt_centers_the_box_at_the_current_pointer() { + for tool in [Tool::RectMarquee, Tool::EllipseMarquee] { + let mut h = harness(tool); + h.state_mut().ui.extras.snap = false; + let o = &mut h.state_mut().ui.tool_options; + o.marquee_style = "fixedSize".into(); + (o.marquee_width, o.marquee_height) = (11.0, 11.0); + press_at(&mut h, 50.4, 60.6, Modifiers::NONE); + mods(&mut h, Modifiers::ALT); + move_to(&mut h, 100.4, 100.6); + assert!(readout(&h, 11, 11)); + release_at(&mut h, 110.4, 90.6, Modifiers::ALT); + assert_eq!(selection(&h), Rect::new(105, 85, 116, 96), "{tool:?}: Alt centres the moving box"); + } +} + /// #204: Preferences › Tools › Show Transformation Values off hides the readout, not the marquee. #[test] fn show_transformation_values_off_hides_the_readout() { diff --git a/crates/ui-egui/src/object_finder.rs b/crates/ui-egui/src/object_finder.rs index 01fe50e502..267c326775 100644 --- a/crates/ui-egui/src/object_finder.rs +++ b/crates/ui-egui/src/object_finder.rs @@ -16,7 +16,7 @@ use std::sync::Arc; use egui::{Color32, TextureOptions, pos2}; use photocraft_algo::selection::Region; use photocraft_doc::{DocId, Document, LayerId}; -use photocraft_engine::smartselect_cmds::{find_object, sampled_target}; +use photocraft_engine::smartselect_cmds::{empty_sampled_area, find_object, sampled_target}; use crate::PhotocraftApp; use crate::canvas::ViewXform; @@ -57,6 +57,13 @@ pub(crate) struct Update { pub shown: Option>, /// A lookup is pending or running: repaint soon to pick it up. pub busy: bool, + /// Why nothing can be found here: [`empty_layer_hint`] when the sampled layer has no pixels under the pointer. + pub hint: Option<&'static str>, +} + +/// The status-bar hint while hovering where the sampled layer has no pixels (the click reports the same as an error). +fn empty_layer_hint() -> &'static str { + tl!("The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels") } /// Whether the preview applies now (`pressed`: a pointer button is down). @@ -87,6 +94,11 @@ pub(crate) fn update(app: &mut PhotocraftApp, at: Option<[f64; 2]>, pressed: boo app.object_finder = Some(Finder { doc, revision, target, all_layers, at: px, since: now, job: None, misses: Vec::new(), shown: None, texture: None }); } let doc_arc = Arc::clone(&st.doc); + // The click reports an empty sampled layer; the hover says so too instead of showing nothing. + let here = photocraft_geom::Rect::from_xywh(px[0], px[1], 1, 1); + if doc_arc.bounds().contains(px[0], px[1]) && empty_sampled_area(&doc_arc, layer, all_layers, here).is_some() { + return Update { shown: None, busy: false, hint: Some(empty_layer_hint()) }; + } let Some(f) = app.object_finder.as_mut() else { return Update::default() }; if f.at != px { f.at = px; @@ -121,7 +133,7 @@ pub(crate) fn update(app: &mut PhotocraftApp, at: Option<[f64; 2]>, pressed: boo f.job = Some(spawn(Arc::clone(&doc_arc), layer, all_layers, px, at)); } let busy = f.job.is_some() || (f.shown.is_none() && !missed && now - f.since < DEBOUNCE); - Update { shown: f.shown.clone(), busy } + Update { shown: f.shown.clone(), busy, hint: None } } fn spawn(doc: Arc, layer: Option, all_layers: bool, px: [i32; 2], at: [f64; 2]) -> Job { @@ -193,6 +205,15 @@ pub(crate) fn draw(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXf if up.busy { ctx.request_repaint_after(std::time::Duration::from_millis(40)); } + match up.hint { + Some(h) if app.ui.status != h => { + app.ui.status = h.to_owned(); + app.ui.status_error = false; + } + // The pointer moved on: drop our hint, never another message. + None if app.ui.status == empty_layer_hint() => app.ui.status.clear(), + _ => {} + } let Some(r) = up.shown else { return }; let Some(f) = app.object_finder.as_mut() else { return }; let key = Arc::as_ptr(&r) as usize; @@ -271,6 +292,19 @@ mod tests { assert!(hover(&mut app, [4.0, 4.0]).shown.is_none()); } + #[test] + fn hovering_an_empty_active_layer_gives_a_hint_not_silence() { + let mut app = disc_app(); + app.session.execute("layer.new.layer", json!({})).unwrap(); + let u = update(&mut app, Some([100.0, 80.0]), false, 100.0); + assert!(u.shown.is_none() && !u.busy); + assert_eq!(u.hint, Some(empty_layer_hint())); + // Sample All Layers on: no hint, the object is looked up. + app.ui.tool_options.sample_all.object_selection = true; + assert!(update(&mut app, Some([100.0, 80.0]), false, 100.0).hint.is_none()); + assert!(hover(&mut app, [100.0, 80.0]).shown.is_some()); + } + #[test] fn preview_clears_on_leave_tool_switch_button_option_and_edits() { let mut app = disc_app(); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index bab926d9f1..6743d9fb4a 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1230,9 +1230,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } }); widgets::vline(ui, 22.0); - if widgets::secondary_button(ui, tl!("Select and Mask…"), 0.0).clicked() { - let _ = crate::menus::invoke(app, ui.ctx(), "select.selectAndMask", json!({})); - } + select_and_mask_button(app, ui); } Tool::Lasso | Tool::PolygonLasso | Tool::MagneticLasso if t.pro => { selection_mode_buttons(app, ui); @@ -1257,9 +1255,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } widgets::vline(ui, 22.0); } - if widgets::secondary_button(ui, tl!("Select and Mask…"), 0.0).clicked() { - let _ = crate::menus::invoke(app, ui.ctx(), "select.selectAndMask", json!({})); - } + select_and_mask_button(app, ui); if crate::lasso_ui::active(app) || (app.ui.tool == Tool::PolygonLasso && !app.ui.polygon.is_empty()) { hint( ui, @@ -1294,9 +1290,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let r = app.run("select.subject", json!({})); crate::retouch_ui::report_smart_result(app, r, tl!("No subject found — try Object Selection or the Lasso")); } - if widgets::secondary_button(ui, tl!("Select and Mask…"), 0.0).clicked() { - let _ = crate::menus::invoke(app, ui.ctx(), "select.selectAndMask", json!({})); - } + select_and_mask_button(app, ui); } Tool::PaintBucket if t.pro => { opt_label(ui, tl!("Fill")); @@ -2553,6 +2547,7 @@ fn hsb_sliders(app: &mut PhotocraftApp, ui: &mut egui::Ui) { app.session.tools.foreground = hsva_srgb(hsva); ui.data_mut(|d| d.insert_temp(key, hsva)); crate::type_tool::foreground_changed(app); + crate::shape_fill_ui::foreground_changed(app, crate::shape_fill_ui::gesture(ui)); } } @@ -4260,6 +4255,7 @@ fn color_field(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } else { app.session.tools.foreground = hsva_srgb(hsva); crate::type_tool::foreground_changed(app); + crate::shape_fill_ui::foreground_changed(app, crate::shape_fill_ui::gesture(ui)); } ui.data_mut(|d| d.insert_temp(key, (hsva.h, hsva.s))); } @@ -4273,6 +4269,7 @@ fn color_field(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } else { app.session.tools.foreground = c; crate::type_tool::foreground_changed(app); + crate::shape_fill_ui::foreground_changed(app, crate::shape_fill_ui::gesture(ui)); } // Keep the hue for greys, so the field marker doesn't jump to red; the saturation with // it, so a later black lands on the corner it was last dragged to (#3228). @@ -4790,6 +4787,16 @@ fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usi } } +/// The options bar's Select and Mask… button: greyed out, as the menu item is, until there is a +/// selection (or a layer mask) to refine (#7520). +fn select_and_mask_button(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let enabled = crate::menus::is_enabled(app, "select.selectAndMask"); + let clicked = ui.add_enabled_ui(enabled, |ui| widgets::secondary_button(ui, tl!("Select and Mask…"), 0.0).clicked()).inner; + if clicked { + let _ = crate::menus::invoke(app, ui.ctx(), "select.selectAndMask", json!({})); + } +} + /// New / add / subtract / intersect selection buttons (shared by selection tools). fn selection_mode_buttons(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.spacing_mut().item_spacing.x = 2.0; @@ -5191,6 +5198,33 @@ mod color_tests { } } + /// #5568: with a shape layer selected, the Color panel recolours its fill (keeping the fill's + /// opacity): a click is one undo step and a whole drag across the field is one more. + #[test] + fn color_panel_recolours_the_selected_shape_fill() { + use photocraft_doc::{Fill, LayerContent, LayerId}; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + let id = LayerId(app.run("shape.create", json!({"rect": [8, 8, 40, 30], "fill": [0.2, 0.4, 0.6, 0.5]})).unwrap()["layer"].as_u64().unwrap()); + let mut h = field_harness(app); + let fill = |app: &PhotocraftApp| match &app.session.active().unwrap().doc.layer(id).unwrap().content { + LayerContent::Shape(sh) => sh.fill.clone(), + _ => panic!("not a shape layer"), + }; + let steps = |app: &PhotocraftApp| app.session.active().unwrap().history.entries().len(); + let before = steps(h.state()); + let min = h.ctx.input(|i| i.viewport_rect()).min; + // The field's top-left: no saturation, full brightness. + click(&mut h, min + vec2(60.0, 10.0)); + let fg = h.state().session.tools.foreground; + assert_eq!(fill(h.state()), Some(Fill::Solid(photocraft_doc::Color::rgba(fg[0], fg[1], fg[2], 0.5)))); + assert_eq!(steps(h.state()), before + 1); + drag(&mut h, min + vec2(120.0, 40.0), min + vec2(250.0, 120.0)); + let fg = h.state().session.tools.foreground; + assert_eq!(fill(h.state()), Some(Fill::Solid(photocraft_doc::Color::rgba(fg[0], fg[1], fg[2], 0.5)))); + assert_eq!(steps(h.state()), before + 2, "the whole drag is one undo step"); + } + /// Press at `from`, move to `to` in six steps and release, as egui sees a drag. fn drag(h: &mut egui_kittest::Harness<'_, PhotocraftApp>, from: egui::Pos2, to: egui::Pos2) { h.hover_at(from); diff --git a/crates/ui-egui/src/path_selection_tests.rs b/crates/ui-egui/src/path_selection_tests.rs index 12b1670863..a5d4ee035a 100644 --- a/crates/ui-egui/src/path_selection_tests.rs +++ b/crates/ui-egui/src/path_selection_tests.rs @@ -248,7 +248,8 @@ fn clicking_shape_with_path_selection_selects_its_layer() { assert_eq!(h.state().session.active().unwrap().active_layer.unwrap().0, rect_layer); } -/// Work paths remain visible (outline) regardless of the active tool. Before the fix, +/// A work path selected in the Paths panel remains visible (outline) regardless of the active tool +/// (one that is not selected is hidden, #6551). Before the fix, /// `draw_overlay` was gated on `vector_tool` so paths disappeared when switching to Brush, Move, /// etc. This test renders the canvas with a Brush tool active and a work path present, then /// confirms the path's outline pixels are painted in the accent colour, proving the path is drawn. @@ -259,6 +260,7 @@ fn path_overlay_drawn_for_non_vector_tools() { app.sync_views(); // Place a work path whose top edge runs exactly along y=10 in document space. app.run("path.set", json!({"name": "work", "path": rect_path(50.0, 10.0, 100.0, 80.0)})).unwrap(); + app.ui.selected_path = Some("work".into()); // Switch to a non-vector tool — before the fix this hid the path outline. app.ui.tool = Tool::Brush; diff --git a/crates/ui-egui/src/pen_draw_tests.rs b/crates/ui-egui/src/pen_draw_tests.rs index 57be940bd6..e11bd4abf2 100644 --- a/crates/ui-egui/src/pen_draw_tests.rs +++ b/crates/ui-egui/src/pen_draw_tests.rs @@ -1,5 +1,5 @@ //! The Pen's modifiers while drawing, through the real canvas (#3306): ⌥/Alt breaks the handle -//! being pulled out and grabs the last anchor's outgoing handle alone, and dragging on the first +//! being pulled out and grabs either of the last anchor's handles alone, and dragging on the first //! anchor closes the path with that anchor's handles pulled out. use egui::{Key, Modifiers, PointerButton, Pos2, vec2}; @@ -122,6 +122,33 @@ fn alt_breaks_the_handle_being_dragged_and_grabs_the_last_outgoing_handle() { assert!(near([k.out_ctrl.x, k.out_ctrl.y], [140.0, 20.0])); } +/// #6077: both handles of the last anchor are drawn, so ⌥/Alt grabs either one, as in Photoshop. +/// Dragging the incoming handle turns it alone; the outgoing handle and the anchors stay put. +#[test] +fn alt_drag_turns_the_last_anchors_incoming_handle_alone() { + let mut h = harness(); + click(&mut h, [20.0, 20.0]); + drag(&mut h, &[[100.0, 20.0], [120.0, 40.0]], None); + let k = h.state().ui.pen.clone().unwrap().knots[1]; + assert!(near(k[1], [80.0, 0.0]) && near(k[2], [120.0, 40.0]), "smooth anchor pulled out: {k:?}"); + + drag(&mut h, &[[80.0, 0.0], [60.0, 30.0]], Some(0)); + let pen = h.state().ui.pen.clone().unwrap(); + assert_eq!(pen.knots.len(), 2, "no new anchor on the handle"); + let k = pen.knots[1]; + assert!(near(k[0], [100.0, 20.0]), "the anchor stayed put: {k:?}"); + assert!(near(k[1], [60.0, 30.0]), "the incoming handle followed the pointer: {k:?}"); + assert!(near(k[2], [120.0, 40.0]), "the outgoing handle stayed put: {k:?}"); + + click(&mut h, [150.0, 120.0]); + h.key_press(Key::Enter); + h.run_steps(2); + let doc = &h.state().session.active().unwrap().doc; + let k = &doc.work_path.as_ref().unwrap().subpaths[0].knots[1]; + assert!(!k.smooth, "the broken handle is a cusp in the work path"); + assert!(near([k.in_ctrl.x, k.in_ctrl.y], [60.0, 30.0]) && near([k.out_ctrl.x, k.out_ctrl.y], [120.0, 40.0])); +} + #[test] fn opening_a_new_document_finishes_the_previous_pen_path() { let mut h = harness(); diff --git a/crates/ui-egui/src/select_and_mask_button_tests.rs b/crates/ui-egui/src/select_and_mask_button_tests.rs new file mode 100644 index 0000000000..5426b778ac --- /dev/null +++ b/crates/ui-egui/src/select_and_mask_button_tests.rs @@ -0,0 +1,46 @@ +//! #7520: the options bar's Select and Mask… button is greyed out while nothing is selected. +use crate::PhotocraftApp; +use crate::state::Tool; +use egui::vec2; +use egui_kittest::{ + Harness, + kittest::{NodeT, Queryable}, +}; +use serde_json::json; + +fn harness(tool: Tool, select: bool) -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + app.sync_views(); + if select { + app.run("select.all", json!({})).unwrap(); + } + app.ui.tool = tool; + let mut h = Harness::builder().with_size(vec2(1400.0, 60.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + crate::panels::options_bar(app, ui); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Pro); + h.run_steps(4); + h +} + +#[test] +fn select_and_mask_is_disabled_without_a_selection() { + for tool in [Tool::RectMarquee, Tool::Lasso, Tool::MagicWand] { + let h = harness(tool, false); + assert!(h.get_by_label("Select and Mask…").accesskit_node().is_disabled(), "{tool:?}: button should be greyed out with no selection"); + } +} + +#[test] +fn select_and_mask_is_enabled_with_a_selection() { + for tool in [Tool::RectMarquee, Tool::Lasso, Tool::MagicWand] { + let h = harness(tool, true); + assert!(!h.get_by_label("Select and Mask…").accesskit_node().is_disabled(), "{tool:?}: button should be live with a selection"); + } +} diff --git a/crates/ui-egui/src/shape_dialog.rs b/crates/ui-egui/src/shape_dialog.rs index f643c210cd..517fa90248 100644 --- a/crates/ui-egui/src/shape_dialog.rs +++ b/crates/ui-egui/src/shape_dialog.rs @@ -2,11 +2,14 @@ //! opens Photoshop's creation dialog instead of drawing. OK runs the same `shape.create` command as //! a drag, with the options bar's fill and stroke, at the clicked point (or centred on it). //! Width and height are stored in document pixels (so automation can set them with -//! `ui.dialog.set`) and shown in the ruler unit. +//! `ui.dialog.set`) and shown in the ruler unit, or in one picked from the Width and Height +//! fields' right-click menu (remembered with the other values). use egui::{Align2, Sense, pos2, vec2}; use serde_json::{Map, Value, json}; +use photocraft_engine::prefs::Unit; + use crate::PhotocraftApp; use crate::state::{DialogKind, Tool}; use crate::theme::Tokens; @@ -15,7 +18,7 @@ const MARK: &str = "__createShape"; /// Largest side the dialog accepts, in pixels (Photoshop's document limit). const MAX_PX: f64 = 300_000.0; /// Remembered values (document pixels; sides and radii come from the options bar). -const KEYS: [&str; 4] = ["width", "height", "fromCenter", "starRatio"]; +const KEYS: [&str; 5] = ["width", "height", "fromCenter", "starRatio", "unit"]; pub fn owns(fields: &Map) -> bool { fields.contains_key(MARK) @@ -40,6 +43,7 @@ fn valid(key: &str, v: &Value) -> bool { match key { "width" | "height" => v.as_f64().is_some_and(|x| x.is_finite() && (1.0..=MAX_PX).contains(&x)), "starRatio" => v.as_f64().is_some_and(|x| x.is_finite() && (1.0..=100.0).contains(&x)), + "unit" => v.as_str().is_some_and(|u| Unit::parse(u).is_some()), _ => v.is_boolean(), } } @@ -68,6 +72,8 @@ pub fn open(app: &mut PhotocraftApp, tool: Tool, at: [f64; 2]) -> Option { f.insert("width".into(), json!(100.0)); f.insert("height".into(), json!(100.0)); f.insert("fromCenter".into(), json!(false)); + // The unit Width and Height show (the ruler unit until one is picked with a right-click). + f.insert("unit".into(), json!(app.session.prefs().units_and_rulers.rulers.name())); match kind { "rectangle" => { f.insert("radii".into(), json!([r, r, r, r])); @@ -160,9 +166,11 @@ fn label(ui: &mut egui::Ui, text: &str) { pub fn body(app: &PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) { ui.spacing_mut().item_spacing.y = 6.0; let kind = f.get(MARK).and_then(Value::as_str).unwrap_or("").to_string(); - // Width and height in the ruler unit (percent of the document's side). + // Width and height in the dialog's unit (percent of the document's side): the ruler unit, or + // the one picked from the fields' right-click menu, as in Photoshop (#7786). let ur = &app.session.prefs().units_and_rulers; - let (unit, ppi) = (ur.rulers, ur.point_size.per_inch()); + let unit = f.get("unit").and_then(Value::as_str).and_then(Unit::parse).unwrap_or(ur.rulers); + let ppi = ur.point_size.per_inch(); let (dpi, size) = app .session .active() @@ -174,10 +182,17 @@ pub fn body(app: &PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) let to_unit = |px: f64| unit.from_px(px, dpi, extent, ppi) as f32; let mut v = to_unit(px); let places = unit.decimals() as u32; - if crate::widgets::value_field_prec(ui, &mut v, to_unit(1.0)..=to_unit(MAX_PX), unit.suffix(), 110.0, places).changed() { + let resp = crate::widgets::value_field_prec(ui, &mut v, to_unit(1.0)..=to_unit(MAX_PX), unit.suffix(), 110.0, places); + if resp.changed() { let px = unit.to_px(f64::from(v), dpi, extent, ppi).clamp(1.0, MAX_PX); f.insert(key.into(), json!((px * 1000.0).round() / 1000.0)); } + // Right-click: the length units, for both fields (the sizes stay in pixels). + resp.context_menu(|ui| { + if let Some(name) = crate::rulers::unit_menu(ui, unit) { + f.insert("unit".into(), json!(name)); + } + }); }); } match kind.as_str() { diff --git a/crates/ui-egui/src/shape_dialog_tests.rs b/crates/ui-egui/src/shape_dialog_tests.rs index a0b5c61596..65d47d81ff 100644 --- a/crates/ui-egui/src/shape_dialog_tests.rs +++ b/crates/ui-egui/src/shape_dialog_tests.rs @@ -76,7 +76,7 @@ fn from_center_and_the_other_tools() { let id = only_dialog(&app); set(&mut app, id, json!({"width": 50, "height": 60, "sides": 7, "starRatio": 50})); crate::dialogs::confirm(&mut app, id).unwrap(); - assert_eq!(live(&app), LiveShape::Polygon { rect: [10.0, 10.0, 50.0, 60.0], sides: 7, star_ratio: 0.5 }); + assert_eq!(live(&app), LiveShape::Polygon { rect: [10.0, 10.0, 50.0, 60.0], sides: 7, star_ratio: 0.5, corner_radius: 0.0 }); press(&mut app, Tool::Triangle, [5.0, 6.0], [5.0, 6.0]); let id = only_dialog(&app); @@ -248,3 +248,57 @@ fn the_dialog_body_renders_for_every_tool_and_unit() { app.ui.close_dialog(id); } } + +/// The texts a dialog body draws. +fn body_texts(app: &PhotocraftApp, fields: &mut serde_json::Map) -> Vec { + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| body(app, ui, fields)); + out.textures_delta.clear(); + let mut texts = Vec::new(); + for clip in &out.shapes { + if let egui::Shape::Text(t) = &clip.shape { + texts.push(t.galley.text().to_string()); + } + } + texts +} + +/// #7786: Width and Height show in the unit picked from their right-click menu (the ruler unit +/// until then), the sizes stay in pixels, and OK remembers the unit with the other values. +#[test] +fn width_and_height_show_in_the_picked_unit_and_remember_it() { + let mut app = app(); + press(&mut app, Tool::Rectangle, [10.0, 10.0], [10.0, 10.0]); + let id = only_dialog(&app); + let mut f = app.ui.dialog_mut(id).unwrap().fields.clone(); + assert_eq!(f["unit"], json!("pixels"), "the ruler unit at first"); + // Each of Width and Height: its label, its value, its unit. + let sides = |texts: &[String]| -> Vec<(String, String)> { + ["Width:", "Height:"] + .iter() + .filter_map(|l| texts.iter().position(|t| t == l)) + .filter_map(|i| Some((texts.get(i + 1)?.clone(), texts.get(i + 2)?.clone()))) + .collect() + }; + let px = ("100".to_string(), "px".to_string()); + assert_eq!(sides(&body_texts(&app, &mut f)), [px.clone(), px], "shown in pixels"); + // What the menu's Millimeters sets: 100 px at the document's 72 ppi is 35.28 mm. + f.insert("unit".into(), json!("mm")); + let mm = ("35.28".to_string(), "mm".to_string()); + assert_eq!(sides(&body_texts(&app, &mut f)), [mm.clone(), mm]); + assert_eq!(f["width"].as_f64(), Some(100.0), "the size stays in pixels"); + set(&mut app, id, json!({"unit": "mm", "width": 50})); + crate::dialogs::confirm(&mut app, id).unwrap(); + // The next Create Rectangle opens in millimetres, with the remembered size. + press(&mut app, Tool::Rectangle, [40.0, 40.0], [40.0, 40.0]); + let id = only_dialog(&app); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!((f["unit"].as_str(), f["width"].as_f64()), (Some("mm"), Some(50.0))); + // A remembered unit that isn't one falls back to the ruler unit. + app.ui.close_dialog(id); + app.session.prefs.edit(|p| { + p.dialogs.insert("shape.create.rectangle".into(), json!({"unit": "furlongs"})); + }); + press(&mut app, Tool::Rectangle, [40.0, 40.0], [40.0, 40.0]); + assert_eq!(app.ui.dialogs[0].fields["unit"], json!("pixels")); +} diff --git a/crates/ui-egui/src/shape_fill_ui.rs b/crates/ui-egui/src/shape_fill_ui.rs index 37731ce243..20d297d725 100644 --- a/crates/ui-egui/src/shape_fill_ui.rs +++ b/crates/ui-egui/src/shape_fill_ui.rs @@ -82,6 +82,41 @@ pub(crate) fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result app.run(COMMAND, json!({"layer": layer.0, "fill": [color.c[0], color.c[1], color.c[2], color.alpha]})) } +/// A new foreground colour from the Color panel, a swatch or the Color Picker fills the selected +/// shape layer, keeping the fill's own opacity, as in Photoshop (#5568). Edits sharing `coalesce` +/// (one Color panel drag) are one undo step. Locked shapes and other layers are left alone. +pub(crate) fn foreground_changed(app: &mut PhotocraftApp, coalesce: Option) { + let Some(st) = app.session.active() else { return }; + let Some(layer) = st.active_layer else { return }; + let Some(LayerContent::Shape(sh)) = st.doc.layer(layer).map(|l| &l.content) else { return }; + let locks = st.doc.effective_locks(layer); + if locks.all || locks.pixels { + return; + } + let fg = app.session.tools.foreground; + let alpha = match &sh.fill { + Some(Fill::Solid(c)) => c.alpha, + _ => 1.0, + }; + if sh.fill == Some(Fill::Solid(Color::rgba(fg[0], fg[1], fg[2], alpha))) { + return; + } + let mut p = json!({"layer": layer.0, "fill": [fg[0], fg[1], fg[2], alpha]}); + if let Some(key) = coalesce { + p["coalesce"] = json!(key); + } + if let Err(e) = app.run(COMMAND, p) { + app.ui.status = e; + app.ui.status_error = true; + } +} + +/// The coalesce key of the pointer gesture under way (its press time), so one Color panel drag +/// is one undo step; `None` for clicks and typed values. +pub(crate) fn gesture(ui: &egui::Ui) -> Option { + ui.input(|i| i.pointer.press_start_time()).map(|t| format!("color-panel-fill:{}", t.to_bits())) +} + /// The canvas preview while the picker is open: the document with the shape re-rendered in the /// picked colour, cached per dialog, revision and colour. pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc, u64)> { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 61de47848f..372bad8474 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -1370,6 +1370,9 @@ pub struct UiState { /// Pen path under construction. #[serde(default)] pub pen: Option, + /// The document the pen path is drawn on; switching away commits it to that Work Path. + #[serde(default)] + pub pen_doc: Option, #[serde(default)] pub stroke_editor: Option, /// Direct Selection tool: selected anchors and the drag in progress (#790). @@ -1576,6 +1579,7 @@ impl Default for UiState { layer_filter_name: String::new(), layer_filter_mode: default_layer_filter_mode(), pen: None, + pen_doc: None, stroke_editor: None, direct_selection: Default::default(), corner_radius: None, diff --git a/crates/ui-egui/src/swatches_ui.rs b/crates/ui-egui/src/swatches_ui.rs index 84990c50bb..3d90fee5ca 100644 --- a/crates/ui-egui/src/swatches_ui.rs +++ b/crates/ui-egui/src/swatches_ui.rs @@ -2,7 +2,8 @@ //! preset browser: groups with disclosure triangles, a colour grid with name tooltips, a context //! menu (Rename, Delete, Move to), and the footer (size · New Group · New Swatch · Delete). //! -//! As in Photoshop, clicking a swatch sets the foreground colour (and recolours selected type); +//! As in Photoshop, clicking a swatch sets the foreground colour (and recolours selected type or +//! fills the selected shape layer); //! Alt- or Ctrl/Cmd-clicking sets the background colour; double-clicking renames it. The //! panel menu (the group's "…" button) adds New Swatch, New Group, Import/Replace (`.aco`, //! `.act`, `.ase`), Export (`.aco`) and Export for Exchange (`.ase`) and Reset. Every action is a @@ -51,8 +52,10 @@ fn colors(app: &PhotocraftApp, ctx: &egui::Context) -> Arc>> { pub fn use_swatch(app: &mut PhotocraftApp, name: &str, background: bool) { let target = if background { "background" } else { "foreground" }; if run(app, "swatches.use", json!({"swatch": name, "target": target})).is_some() && !background { - // Clicking a swatch while characters are selected recolours them (#364). + // Clicking a swatch while characters are selected recolours them (#364), and with a + // shape layer selected fills the shape (#5568). crate::type_tool::foreground_changed(app); + crate::shape_fill_ui::foreground_changed(app, None); } } @@ -290,6 +293,31 @@ mod tests { assert_eq!((r["format"].as_str(), r["count"].as_u64()), (Some("act"), Some(256))); } + /// #5568: clicking a swatch with a shape layer selected fills the shape with it, one undo + /// step each, a gradient fill included. + #[test] + fn swatch_click_fills_the_selected_shape() { + use photocraft_doc::{Fill, LayerContent, LayerId}; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + let id = LayerId(app.run("shape.create", json!({"rect": [8, 8, 40, 30], "fill": {"gradient": {}}})).unwrap()["layer"].as_u64().unwrap()); + let fill = |app: &PhotocraftApp| match &app.session.active().unwrap().doc.layer(id).unwrap().content { + LayerContent::Shape(sh) => sh.fill.clone(), + _ => panic!("not a shape layer"), + }; + let steps = |app: &PhotocraftApp| app.session.active().unwrap().history.entries().len(); + let before = steps(&app); + let names: Vec = app.session.presets.swatches[0].items.iter().take(2).map(|s| s.name.clone()).collect(); + use_swatch(&mut app, &names[0], false); + let fg = app.session.tools.foreground; + assert_eq!(fill(&app), Some(Fill::Solid(photocraft_doc::Color::rgba(fg[0], fg[1], fg[2], 1.0))), "{} filled the shape", names[0]); + assert_eq!(steps(&app), before + 1); + use_swatch(&mut app, &names[1], false); + let fg = app.session.tools.foreground; + assert_eq!(fill(&app), Some(Fill::Solid(photocraft_doc::Color::rgba(fg[0], fg[1], fg[2], 1.0))), "{} filled the shape", names[1]); + assert_eq!(steps(&app), before + 2, "each swatch click is its own undo step"); + } + fn click(h: &mut Harness<'_, PhotocraftApp>, p: egui::Pos2, modifiers: Modifiers) { h.hover_at(p); // Held modifiers, as when the user holds Alt while clicking. diff --git a/crates/ui-egui/src/vector_paint_tests.rs b/crates/ui-egui/src/vector_paint_tests.rs index 321a106cb0..c818b75958 100644 --- a/crates/ui-egui/src/vector_paint_tests.rs +++ b/crates/ui-egui/src/vector_paint_tests.rs @@ -189,6 +189,84 @@ fn fill_and_stroke_swatches_open_with_pointer_and_keyboard_without_changing_pain } } +#[test] +fn shape_paint_hex_input_keeps_partial_text_and_preserves_alpha() { + for label in ["Fill color", "Stroke color"] { + let mut app = app("rgb", 32); + let original = [0.123456, 0.345678, 0.567891, 0.372819]; + app.run("tools.setColors", json!({"foreground": original, "background": original})).unwrap(); + let mut h = harness(app, false); + h.get_by_label(label).click(); + h.run_steps(3); + h.get_by_label("#").click(); + h.run_steps(1); + h.key_press_modifiers(Modifiers::COMMAND, Key::A); + h.run_steps(1); + for ch in "12AB".chars() { + h.event(Event::Text(ch.to_string())); + h.run_steps(1); + } + assert_eq!(h.get_by_label("#").value().as_deref(), Some("12AB")); + assert_eq!(h.state().session.tools.foreground, original); + assert_eq!(h.state().session.tools.background, original); + for ch in "EF".chars() { + h.event(Event::Text(ch.to_string())); + h.run_steps(1); + } + let want = [18.0 / 255.0, 171.0 / 255.0, 239.0 / 255.0, original[3]]; + let tools = &h.state().session.tools; + assert_eq!(if label == "Fill color" { tools.foreground } else { tools.background }, want); + assert_eq!(if label == "Fill color" { tools.background } else { tools.foreground }, original); + // An invalid/multi-byte value leaves the selected paint untouched. + h.key_press_modifiers(Modifiers::COMMAND, Key::A); + h.run_steps(1); + h.event(Event::Text("颜色".into())); + h.run_steps(2); + let tools = &h.state().session.tools; + assert_eq!(if label == "Fill color" { tools.foreground } else { tools.background }, want); + h.key_press(Key::Escape); + h.run_steps(2); + finish_shape(h.state_mut(), Tool::Rectangle, [20.0, 20.0], [100.0, 80.0], Modifiers::NONE); + let drawn = shape(h.state()); + let paint = if label == "Fill color" { drawn.fill.as_ref().and_then(color_of) } else { drawn.stroke.as_ref().and_then(|s| color_of(&s.paint)) }; + assert_eq!(paint, Some(want), "the next shape takes the typed colour"); + } +} + +#[test] +fn a_pasted_hex_colour_updates_the_selected_shape_and_undoes_once() { + for label in ["Fill color", "Stroke color"] { + let mut app = app("rgb", 32); + app.run("tools.setColors", json!({"foreground": [0.2, 0.3, 0.4, 0.6], "background": [0.5, 0.6, 0.7, 0.8]})).unwrap(); + app.ui.tool_options.stroke_width = 3.0; + finish_shape(&mut app, Tool::Rectangle, [20.0, 20.0], [100.0, 80.0], Modifiers::NONE); + let original = shape(&app).clone(); + let history = app.session.active().unwrap().history.past_len(); + let mut h = harness(app, false); + h.get_by_label(label).click(); + h.run_steps(3); + h.get_by_label("#").click(); + h.run_steps(1); + h.key_press_modifiers(Modifiers::COMMAND, Key::A); + h.run_steps(1); + h.event(Event::Paste("#12ABEF".into())); + h.run_steps(2); + let edited = shape(h.state()); + let (paint, untouched) = if label == "Fill color" { + (edited.fill.as_ref().and_then(color_of), edited.stroke == original.stroke) + } else { + (edited.stroke.as_ref().and_then(|s| color_of(&s.paint)), edited.fill == original.fill) + }; + let alpha = if label == "Fill color" { 0.6 } else { 0.8 }; + let want = [18.0 / 255.0, 171.0 / 255.0, 239.0 / 255.0, alpha]; + assert_eq!(paint, Some(want)); + assert!(untouched, "the other paint stays unchanged"); + assert_eq!(h.state().session.active().unwrap().history.past_len(), history + 1); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(shape(h.state()), &original); + } +} + #[test] fn picking_tool_colours_enables_paint_and_reaches_the_next_shape() { let mut app = app("rgb", 32); diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index 562d844011..56036a8520 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -26,6 +26,9 @@ pub struct PenPath { /// Re-dragging the final anchor changes its outgoing control only; the incoming curve stays put. #[serde(skip)] pub adjusting_last: bool, + /// ⌥/Alt-dragging the final anchor's incoming handle: it turns alone (#6077). + #[serde(skip)] + pub adjusting_in: bool, /// Drawn with the Curvature Pen: handles follow the anchors (`curvature_refresh`) and /// `unlinked` lists its corner points. #[serde(default)] @@ -237,7 +240,13 @@ pub fn closes_path_at(app: &PhotocraftApp, tool: Tool, x: f64, y: f64) -> bool { /// or add a new anchor (dragging a new anchor pulls symmetrical handles). With ⇧ a new anchor /// lands on the nearest 45° ray from the previous one, at the pointer's projection onto it. pub fn pen_down(app: &mut PhotocraftApp, x: f64, y: f64, shift: bool) { + if !cancel_stale(app) { + return; + } let tol = 6.0 / app.point_zoom().max(0.01) as f64; + if app.ui.pen.is_none() { + app.ui.pen_doc = app.session.active().map(|st| st.doc.id); + } // A press on an endpoint of an open path carries on drawing it, as in Photoshop (#6593): the // press then acts on its last anchor, so a drag pulls out the next segment's handle. if app.ui.pen.as_ref().is_none_or(|p| p.knots.is_empty()) @@ -246,6 +255,7 @@ pub fn pen_down(app: &mut PhotocraftApp, x: f64, y: f64, shift: bool) { app.ui.pen = Some(resumed); } let pen = app.ui.pen.get_or_insert_with(PenPath::default); + pen.adjusting_in = false; if let Some(first) = pen.knots.first().map(|k| k[0]) && on_closing_anchor(pen, x, y, tol) { @@ -285,10 +295,14 @@ pub fn pen_move(app: &mut PhotocraftApp, x: f64, y: f64, shift: bool) { if let Some(pen) = app.ui.pen.as_mut() && pen.dragging { - let adjusting_last = pen.adjusting_last; + let (adjusting_last, adjusting_in) = (pen.adjusting_last, pen.adjusting_in); if let Some(k) = pen.knots.last_mut() { let a = k[0]; let [x, y] = shift_45(a, [x, y], shift); + if adjusting_in { + k[1] = [x, y]; + return; + } k[2] = [x, y]; if !adjusting_last { k[1] = [2.0 * a[0] - x, 2.0 * a[1] - y]; @@ -307,6 +321,9 @@ fn shift_45(origin: [f64; 2], p: [f64; 2], shift: bool) -> [f64; 2] { /// clicking the first anchor commits the path. Therefore Cmd/Ctrl+Z must consume one /// knot rather than undoing an unrelated, already committed document operation. pub fn pen_undo_last_point(app: &mut PhotocraftApp) -> bool { + if !cancel_stale(app) { + return false; + } let Some(pen) = app.ui.pen.as_mut() else { return false }; // A resumed path's own anchors are not the draft's to take back: undoing past the ones added // since it resumed drops the draft and leaves the path as it was (#6593). @@ -345,6 +362,7 @@ pub fn pen_up(app: &mut PhotocraftApp) { let Some(pen) = app.ui.pen.as_mut() else { return }; pen.dragging = false; pen.adjusting_last = false; + pen.adjusting_in = false; if closing(pen) { // A click on the first anchor closes the path and leaves that anchor's handles alone. if let Some(k) = pen.knots.last_mut() @@ -368,21 +386,31 @@ pub fn pen_break_handle(app: &mut PhotocraftApp) { pen.adjusting_last = true; } -/// ⌥/Alt-press on the last anchor's outgoing handle while drawing grabs that handle alone: the -/// drag turns it and the incoming curve stays put (#3306). False when the press misses it. +/// ⌥/Alt-press on one of the last anchor's handles while drawing grabs that handle alone: the +/// drag turns it and the other handle stays put (#3306, #6077). False when the press misses both. pub fn pen_grab_handle(app: &mut PhotocraftApp, x: f64, y: f64) -> bool { let tol = 6.0 / f64::from(app.point_zoom().max(0.01)); let Some(pen) = app.ui.pen.as_mut() else { return false }; let Some((i, k)) = pen.knots.len().checked_sub(1).zip(pen.knots.last()) else { return false }; let dist = |p: [f64; 2]| (p[0] - x).hypot(p[1] - y); - if k[2] == k[0] || dist(k[2]) >= tol || dist(k[2]) >= dist(k[0]) { + // Both handles show only while the outgoing one is pulled out (`draw_overlay`). + if k[2] == k[0] { return false; } + let grabbed = |h: [f64; 2]| h != k[0] && dist(h) < tol && dist(h) < dist(k[0]); + let incoming = if grabbed(k[2]) && dist(k[2]) <= dist(k[1]) { + false + } else if grabbed(k[1]) { + true + } else { + return false; + }; if !pen.unlinked.contains(&i) { pen.unlinked.push(i); } pen.dragging = true; pen.adjusting_last = true; + pen.adjusting_in = incoming; true } @@ -446,6 +474,7 @@ pub(crate) fn pen_replace(app: &mut PhotocraftApp, path: &Path) { pen.unlinked = knots.iter().enumerate().filter(|(_, k)| !k.smooth).map(|(i, _)| i).collect(); pen.dragging = false; pen.adjusting_last = false; + pen.adjusting_in = false; pen.moving = None; if pen.curvature { curvature_refresh(pen, false); @@ -455,9 +484,50 @@ pub(crate) fn pen_replace(app: &mut PhotocraftApp, path: &Path) { } } +/// Preserve a foreign document's draft as its Work Path before another document can edit it. +/// A failed commit retains the draft and blocks the caller until it can be retried. +pub fn cancel_stale(app: &mut PhotocraftApp) -> bool { + let active = app.session.active().map(|st| st.doc.id); + if app.ui.pen.is_none() { + app.ui.pen_doc = None; + return true; + } + let Some(owner) = app.ui.pen_doc.filter(|d| Some(*d) != active) else { return true }; + if let Some(index) = app.session.documents().iter().position(|d| d.doc.id == owner) { + let Some(mut pen) = app.ui.pen.clone() else { return true }; + if pen.curvature { + curvature_refresh(&mut pen, false); + } + let active = app.session.active_index(); + app.session.set_active(index); + // Bypass shell targets and sync_views: both belong to the newly active document. + let result = app.session.execute("path.set", json!({"name": "work", "op": "combine", "path": pen_to_json(&pen, false)})); + if let Some(index) = active { + app.session.set_active(index); + } + if let Err(e) = result { + app.ui.status = e.to_string(); + app.ui.status_error = true; + return false; + } + } + app.ui.pen = None; + app.ui.pen_doc = None; + if app.ui.direct_selection.target == Some(crate::direct_select::PathRef::Pen) { + app.ui.direct_selection = Default::default(); + } + true +} + /// Finish the pen path: a work path (Path mode) or a new shape layer (Shape mode). pub fn pen_commit(app: &mut PhotocraftApp, closed: bool) { + // The control channel is drained after `sync_views`, so a document switch in this frame has + // not been seen yet: never commit the path into the document it was not drawn on. + if !cancel_stale(app) { + return; + } let Some(mut pen) = app.ui.pen.take() else { return }; + app.ui.pen_doc = None; if app.ui.direct_selection.target == Some(crate::direct_select::PathRef::Pen) { // Its anchors are no longer the Pen's: the committed path is another path. app.ui.direct_selection = Default::default(); @@ -688,6 +758,9 @@ fn curvature_hit(app: &PhotocraftApp, x: f64, y: f64) -> Option { /// Curvature Pen press: clicking the first anchor closes the path, pressing another anchor /// grabs it to move, anywhere else adds a smooth anchor (still draggable until the release). pub fn curvature_down(app: &mut PhotocraftApp, x: f64, y: f64) { + if !cancel_stale(app) { + return; + } if app.ui.pen.as_ref().is_some_and(|p| !p.curvature) { pen_commit(app, false); } @@ -696,6 +769,9 @@ pub fn curvature_down(app: &mut PhotocraftApp, x: f64, y: f64) { pen_commit(app, true); return; } + if app.ui.pen.is_none() { + app.ui.pen_doc = app.session.active().map(|st| st.doc.id); + } let pen = app.ui.pen.get_or_insert_with(|| PenPath { curvature: true, ..Default::default() }); if hit.is_none() { pen.knots.push([[x, y]; 3]); @@ -1106,7 +1182,7 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform // View › Show › Target Path (under Extras) hides the paths; the Pen's path in progress stays. // Free Transform Path draws the path through its box instead (transform_tool). let paths = app.ui.view.shows(app.ui.view.show.target_path) && app.ui.transform.as_ref().is_none_or(|t| t.path.is_none()); - if crate::direct_select::shows(app, painter.ctx().input(|i| i.modifiers)) { + if app.session.active().is_some_and(|st| st.doc.id == doc.id) && 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, paths); if paths { @@ -1117,10 +1193,15 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform crate::anchor_tools::draw_hover(app, painter, &to_scr, accent, painter.ctx().input(|i| i.modifiers)); } } else if paths { - // Paths remain visible as outlines with any tool. Path Selection and the Pen tools add - // anchor squares; Direct Selection has its own overlay above. + // The shape's or vector mask's path stays visible as an outline with any tool. Path + // Selection and the Pen tools add anchor squares; Direct Selection has its own overlay + // above. The Work Path is drawn only while it is the path selected in the Paths panel or + // a path tool is active, as in Photoshop: a Work Path left unselected (one a PSD arrived + // with, or after Ctrl-click Load as Selection) would otherwise cover the marching ants (#6551). let anchors = shows_anchors(tool); - if let Some(wp) = &doc.work_path { + if let Some(wp) = &doc.work_path + && (anchors || app.ui.selected_path.as_deref() == Some("work")) + { draw_path(wp, anchors); } // The saved path selected in the Paths panel is the target path (#6550). @@ -1142,7 +1223,8 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform // Pen path in progress, with handles of the last knot and a rubber band to the pointer. While // ⌘/Ctrl drags it, Direct Selection draws its preview instead. let edited = matches!(&app.ui.direct_selection.drag, Some(crate::direct_select::Drag::Edit { target: crate::direct_select::PathRef::Pen, .. })); - if let Some(pen) = app.ui.pen.as_ref().filter(|_| !edited) { + // Only on the document it is drawn on: a view of another document never shows it (#5373). + if let Some(pen) = app.ui.pen.as_ref().filter(|_| !edited).filter(|_| app.ui.pen_doc.is_none_or(|d| d == doc.id)) { let mut pts = Vec::new(); // A close-drag shows the first segment bending with the handle being pulled out. let mut knots = pen.knots.clone(); @@ -1581,6 +1663,37 @@ fn swatch(ui: &mut egui::Ui, fill: Option<&photocraft_doc::Fill>, fallback: [f32 out = Some(PaintChange::Color(color)); } } + ui.horizontal(|ui| { + let label = ui.label(egui::RichText::new("#").color(t.text_dim)); + let id = resp.id.with("shape-paint-hex"); + let selected = match out { + Some(PaintChange::Color(color)) => color, + _ => original, + }; + // Keep incomplete typing across frames; deriving the field from the colour each + // frame would discard every keystroke until all six digits were present. + let mut text = ui + .data(|d| d.get_temp::<([f32; 4], String)>(id)) + .filter(|(previous, _)| *previous == selected) + .map_or_else(|| hex(selected).trim_start_matches('#').to_ascii_uppercase(), |(_, text)| text); + let response = crate::widgets::text_field(ui, id, &mut text, "RRGGBB", 90.0, true).labelled_by(label.id); + if response.changed() + && let Some(rgb) = crate::color_picker_ui::parse_hex(&text) + { + let color = [rgb[0], rgb[1], rgb[2], selected[3]]; + out = Some(PaintChange::Color(color)); + hsva = picker_color(color); + } + if response.has_focus() { + let color = match out { + Some(PaintChange::Color(color)) => color, + _ => selected, + }; + ui.data_mut(|d| d.insert_temp(id, (color, text))); + } else { + ui.data_mut(|d| d.remove::<([f32; 4], String)>(id)); + } + }); if let Some(rgb) = crate::screen_picker::button(ui, screen_id) { out = Some(sampled(rgb)); hsva = picker_color([rgb[0], rgb[1], rgb[2], original[3]]); @@ -1729,7 +1842,14 @@ pub fn shape_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: photocra }); } } - photocraft_doc::vector::LiveShape::Polygon { sides, star_ratio, .. } => { + photocraft_doc::vector::LiveShape::Polygon { sides, star_ratio, corner_radius, .. } => { + // One radius rounds every vertex of a triangle, polygon or star, as in Photoshop. + ui.horizontal(|ui| { + let mut r = *corner_radius as f32; + if num(ui, tl!("Corner radius"), &mut r, 0.0..=100000.0, "px") { + edit = Some(json!({"cornerRadius": r, "coalesce": key("corner-radius")})); + } + }); ui.horizontal(|ui| { let mut n = *sides as f32; if num(ui, tl!("Sides"), &mut n, 3.0..=100.0, "") { @@ -1852,6 +1972,32 @@ pub fn paths_footer(ctx: &egui::Context) -> Option { ctx.data(|d| d.get_temp(footer_id())) } +/// Photoshop's default name for a saved path: the first free "Path N", counting from the number of +/// saved paths. +fn free_path_name(doc: &Document) -> String { + let mut n = doc.paths.len().saturating_add(1); + while doc.paths.iter().any(|p| p.name == format!("Path {n}")) { + n = n.saturating_add(1); + if n == usize::MAX { + break; + } + } + format!("Path {n}") +} + +/// A Paths panel name being typed in place: the row's key ("work" or the saved path's name) and +/// the text so far. It lives in egui's temp data, like the Channels panel's rename field. +#[derive(Clone)] +struct PathRename { + key: String, + text: String, + focused: bool, +} + +fn rename_id(doc: &Document) -> egui::Id { + egui::Id::new(("path-rename", doc.id.0)) +} + /// Commands offered by a Paths row's context menu. The row's path is passed explicitly so a /// right-click acts on that row even when another path is selected in the panel. `layer` is the /// layer a `PathRow::Layer` row belongs to (the active layer). @@ -1867,14 +2013,7 @@ fn path_context_actions(entry: &PathEntry, doc: &Document, layer: Option(path_drag_key())); } let drag = ctx.data(|d| d.get_temp::(path_drag_key())); + let rename_id = rename_id(&doc); // The buttons sit in a footer at the panel's bottom, like Photoshop's. let footer = crate::widgets::footer_height(ui) + ui.spacing().item_spacing.y; let fill = ui.available_height() > footer + 60.0; @@ -1965,8 +2109,38 @@ pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { 0.0, egui::TextFormat { font_id: egui::FontId::proportional(12.0), color: t.text, italics, ..Default::default() }, ); - let g = ui.painter().layout_job(job); - ui.painter().galley(pos2(r.left() + 46.0, r.center().y - g.size().y / 2.0), g, t.text); + let renaming = ui.ctx().data(|d| d.get_temp::(rename_id)).filter(|e| e.key == key); + if let Some(mut edit) = renaming { + let field = Rect::from_min_max(pos2(r.left() + 43.0, r.center().y - 11.0), pos2(r.right() - 8.0, r.center().y + 11.0)); + let id = rename_id.with("field"); + let te = ui.put(field, egui::TextEdit::singleline(&mut edit.text).id(id).font(egui::FontId::proportional(12.0))); + let (enter, esc) = ui.input(|i| (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape))); + if !edit.focused { + te.request_focus(); + // Like Photoshop, the whole name starts selected so typing replaces it. + if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), id) { + let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(edit.text.chars().count())); + state.cursor.set_char_range(Some(all)); + state.store(ui.ctx(), id); + } + edit.focused = true; + ui.ctx().data_mut(|d| d.insert_temp(rename_id, edit)); + } else if esc { + ui.ctx().data_mut(|d| d.remove::(rename_id)); + } else if enter || te.lost_focus() || !te.has_focus() { + ui.ctx().data_mut(|d| d.remove::(rename_id)); + let to = edit.text.trim(); + // The Work Path is saved even under its proposed name; an unchanged saved name is left alone. + if !to.is_empty() && (*kind == PathRow::Work || to != name) { + action = Some(("path.rename", json!({"name": key, "to": to}))); + } + } else { + ui.ctx().data_mut(|d| d.insert_temp(rename_id, edit)); + } + } else { + let g = ui.painter().layout_job(job); + ui.painter().galley(pos2(r.left() + 46.0, r.center().y - g.size().y / 2.0), g, t.text); + } // Ctrl/Cmd-click loads only the thumbnail's path, without changing the panel target. let modifiers = ui.input(|i| i.modifiers); let load = resp.clicked() && modifiers.command && resp.interact_pointer_pos().is_some_and(|p| thumbnail.contains(p)); @@ -1979,10 +2153,18 @@ pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } else if resp.clicked() { app.ui.selected_path = Some(key.clone()); } - // Double-clicking the Work Path saves it (Photoshop's "Save Path"). - if !load && resp.double_clicked() && *kind == PathRow::Work { - let n = doc.paths.len() + 1; - action = Some(("path.rename", json!({"name": "work", "to": format!("Path {n}")}))); + // As in Photoshop, double-clicking a saved path's name renames it, and double-clicking + // the Work Path asks for the name to save it under (#6238). + let proposed = match kind { + PathRow::Work => Some(free_path_name(&doc)), + PathRow::Saved => Some(name.clone()), + PathRow::Layer => None, + }; + if !load + && resp.double_clicked() + && let Some(text) = proposed + { + ui.ctx().data_mut(|d| d.insert_temp(rename_id, PathRename { key: key.clone(), text, focused: false })); } resp.context_menu(|ui| { crate::widgets::menu_scroll(ui, |ui| { @@ -1995,6 +2177,9 @@ pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if cmd == "path.stroke" { // Stroke Path… asks for the tool first, as in Photoshop (#2978). stroke_dialog = Some(params.get("name").and_then(Value::as_str).unwrap_or("work").to_string()); + } else if cmd == "path.rename" && params.get("to").is_none() { + let edit = PathRename { key: key.clone(), text: name.clone(), focused: false }; + ui.ctx().data_mut(|d| d.insert_temp(rename_id, edit)); } else { action = Some((cmd, params)); } @@ -2073,11 +2258,20 @@ pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if let Some(name) = stroke_dialog { crate::canvas_tool_menu::stroke_path_dialog(app, &name); } - if let Some((cmd, p)) = action - && let Err(e) = app.run(cmd, p) - { - app.ui.status = e; - app.ui.status_error = true; + if let Some((cmd, p)) = action { + let renamed = if cmd == "path.rename" { p.get("to").and_then(Value::as_str).map(str::to_string) } else { None }; + match app.run(cmd, p) { + // The renamed (or just saved) path stays the panel's target. + Ok(_) => { + if renamed.is_some() { + app.ui.selected_path = renamed; + } + } + Err(e) => { + app.ui.status = e; + app.ui.status_error = true; + } + } } } @@ -2242,6 +2436,61 @@ mod tests { assert!(shape.and_then(|id| app.session.active().unwrap().doc.layer(id)).is_some(), "one undo step brings it back"); } + /// #6238: double-clicking a saved path's name renames it in place, and double-clicking the + /// Work Path saves it under the name typed (it used to save it as "Path N" straight away). + #[test] + fn double_clicking_a_path_name_renames_it_in_place() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = app(); + let path = json!({"subpaths": [{"closed": true, "knots": [ + {"anchor": [10, 10], "in": [10, 10], "out": [10, 10]}, + {"anchor": [80, 10], "in": [80, 10], "out": [80, 10]}, + {"anchor": [80, 80], "in": [80, 80], "out": [80, 80]} + ]}]}); + app.run("path.set", json!({"name": "First", "path": path})).unwrap(); + app.run("path.set", json!({"name": "work", "path": path})).unwrap(); + // 60 fps steps, so two clicks a frame apart count as a double-click. + let mut h = Harness::builder().with_size(vec2(320.0, 260.0)).with_step_dt(1.0 / 60.0).build_ui_state(|ui, app| paths_panel(app, ui), app); + h.run_steps(3); + let rename = |h: &mut Harness<'_, PhotocraftApp>, row: &str, text: &str| { + let name = h.get_by_label(row).rect().left_center() + vec2(60.0, 0.0); + h.event(egui::Event::PointerMoved(name)); + h.run_steps(30); + for pressed in [true, false, true, false] { + h.event(egui::Event::PointerButton { pos: name, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + h.step(); + } + h.run_steps(3); + h.event(egui::Event::Text(text.into())); + h.run_steps(1); + h.key_press(egui::Key::Enter); + h.run_steps(3); + }; + let names = |h: &Harness<'_, PhotocraftApp>| h.state().session.active().unwrap().doc.paths.iter().map(|p| p.name.clone()).collect::>(); + rename(&mut h, "First", "Outline"); + assert_eq!(names(&h), ["Outline"]); + assert!(h.state().session.active().unwrap().doc.work_path.is_some(), "renaming a saved path leaves the work path alone"); + rename(&mut h, "Work Path", "Circle"); + assert_eq!(names(&h), ["Outline", "Circle"]); + assert!(h.state().session.active().unwrap().doc.work_path.is_none()); + assert_eq!(h.state().ui.selected_path.as_deref(), Some("Circle")); + } + + /// #6238: a saved path's context menu offers Rename Path…, as the Layers panel's does. + #[test] + fn saved_path_context_menu_offers_rename() { + let mut app = app(); + app.run("path.set", json!({"name": "First", "path": {"subpaths": []}})).unwrap(); + app.run("path.set", json!({"name": "work", "path": {"subpaths": []}})).unwrap(); + let doc = &app.session.active().unwrap().doc; + let rename = |kind| { + let entry = path_rows(doc, None).into_iter().find(|r| r.kind == kind).unwrap(); + path_context_actions(&entry, doc, None).into_iter().find(|(label, _, _)| *label == "Rename Path…").map(|(_, id, p)| (id, p)) + }; + assert_eq!(rename(PathRow::Saved), Some(("path.rename", json!({"name": "First"})))); + assert_eq!(rename(PathRow::Work), None, "the work path is saved, not renamed"); + } + /// How many shapes the path overlay paints for `app` this frame. fn overlay_shapes(app: &PhotocraftApp, ctx: &egui::Context) -> usize { let doc = app.session.active().unwrap().doc.clone(); @@ -2279,6 +2528,43 @@ mod tests { assert!(!shows_anchors(Tool::Brush) && !shows_anchors(Tool::Move)); } + #[test] + fn an_unselected_work_path_is_not_drawn_over_the_canvas() { + // #6551: Ctrl-click Load as Selection leaves the Paths panel's selection alone, yet the Work + // Path stayed drawn over the new selection's edge. Photoshop draws it only while selected + // in the Paths panel (or a path tool is active). + let ctx = egui::Context::default(); + let mut app = app(); + let path = json!({"subpaths": [{"closed": true, "knots": [ + {"anchor": [20, 20], "in": [20, 20], "out": [20, 20]}, + {"anchor": [90, 20], "in": [90, 20], "out": [90, 20]}, + {"anchor": [90, 90], "in": [90, 90], "out": [90, 90]} + ]}]}); + app.run("path.set", json!({"name": "work", "path": path})).unwrap(); + app.run("path.toSelection", json!({"name": "work", "mode": "replace"})).unwrap(); + assert!(app.ui.selected_path.is_none()); + for tool in [Tool::Move, Tool::Brush, Tool::Rectangle, Tool::Zoom] { + app.ui.tool = tool; + assert_eq!(overlay_shapes(&app, &ctx), 0, "{tool:?}: an unselected Work Path stays off the canvas"); + } + // Selected in the Paths panel: drawn with any tool. + app.ui.selected_path = Some("work".into()); + for tool in [Tool::Move, Tool::Brush] { + app.ui.tool = tool; + assert!(overlay_shapes(&app, &ctx) > 0, "{tool:?}: the selected Work Path is drawn"); + } + // Another path selected: the Work Path is not the target path. + app.ui.selected_path = Some("Saved".into()); + app.ui.tool = Tool::Move; + assert_eq!(overlay_shapes(&app, &ctx), 0); + // A path tool shows the Work Path it edits even when the panel selection was cleared. + app.ui.selected_path = None; + for tool in [Tool::Pen, Tool::PathSelection] { + app.ui.tool = tool; + assert!(overlay_shapes(&app, &ctx) > 0, "{tool:?}: path tools draw the Work Path"); + } + } + #[test] fn show_target_path_and_extras_hide_the_path_outlines() { // #1119: View › Show › Target Path only flipped its checkmark; the canvas kept drawing the @@ -2521,6 +2807,287 @@ mod tests { pen_up(&mut app); } + /// #5373: an unfinished path stays with its own document through same-frame switches. + #[test] + fn an_unclosed_path_stays_with_its_document() { + // Both pens claim the document at their first anchor. + let open_path = |app: &mut PhotocraftApp| { + for (x, y) in [(20.0, 20.0), (120.0, 60.0)] { + if app.ui.tool == Tool::CurvaturePen { + curvature_down(app, x, y); + curvature_up(app); + } else { + pen_down(app, x, y, false); + pen_up(app); + } + } + }; + let work = |app: &PhotocraftApp, i: usize| app.session.documents()[i].doc.work_path.clone(); + // File › New commits the path to its original document. + for tool in [Tool::Pen, Tool::CurvaturePen] { + let mut app = app(); + app.ui.tool = tool; + open_path(&mut app); + assert!(app.ui.pen.is_some() && app.ui.pen_doc.is_some(), "{tool:?}: claimed by its document"); + app.run("file.new", json!({"width": 300, "height": 150})).unwrap(); + assert!(app.ui.pen.is_none() && app.ui.pen_doc.is_none(), "{tool:?}: the first document's path is gone"); + assert!(work(&app, 0).is_some(), "{tool:?}: the original keeps its Work Path"); + assert_eq!(work(&app, 1), None, "{tool:?}: the new document is blank"); + } + // Two documents; the path below is drawn on the second (active) one. + let mut app = app(); + app.ui.tool = Tool::Pen; + app.run("file.new", json!({"width": 300, "height": 150})).unwrap(); + open_path(&mut app); + assert!(app.session.set_active(0)); + pen_commit(&mut app, false); + assert!(app.ui.pen.is_none()); + assert!(work(&app, 0).is_none(), "the active document stays untouched"); + assert!(work(&app, 1).is_some(), "the owner keeps its Work Path"); + // Staying on its own document, the path still commits there. + open_path(&mut app); + pen_commit(&mut app, false); + assert!(work(&app, 0).is_some(), "it commits to the document it was drawn on"); + assert!(work(&app, 1).is_some()); + for act in [0, 1, 2] { + open_path(&mut app); + assert!(app.session.set_active(1)); + match act { + 0 => pen_down(&mut app, 50.0, 50.0, false), + 1 => curvature_down(&mut app, 50.0, 50.0), + _ => assert!(!pen_undo_last_point(&mut app), "nothing of this document's to undo"), + } + let knots = app.ui.pen.as_ref().map_or(0, |p| p.knots.len()); + assert!(knots <= 1, "act {act}: a new path, not the other document's (knots {knots})"); + // The two drawing entry points start a path of their own here; undo has none to take. + let expected = if act == 2 { None } else { app.session.active().map(|st| st.doc.id) }; + assert_eq!(app.ui.pen_doc, expected, "act {act}"); + app.ui.pen = None; + app.ui.pen_doc = None; + assert!(app.session.set_active(0)); + } + open_path(&mut app); + let shapes = |app: &PhotocraftApp| { + let ctx = egui::Context::default(); + let doc = app.session.active().unwrap().doc.clone(); + let xf = ViewXform { + rect: egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(400.0, 300.0)), + zoom: 1.0, + center: [100.0, 75.0], + flip: false, + rotation: 0.0, + aspect: 1.0, + }; + let mut out = ctx.run_ui(Default::default(), |ui| draw_overlay(app, ui.painter(), &xf, &doc)); + out.textures_delta.clear(); + out.shapes.len() + }; + // Same view, pen present vs absent: the difference is the path itself, nothing else. + let drawn = |app: &mut PhotocraftApp| { + let with_pen = shapes(app); + let pen = app.ui.pen.take(); + let without = shapes(app); + app.ui.pen = pen; + with_pen - without + }; + assert!(drawn(&mut app) > 0, "its own document draws the path"); + assert!(app.session.set_active(1)); + assert_eq!(drawn(&mut app), 0, "the other document draws nothing of it"); + assert!(app.session.set_active(0)); + app.ui.pen = None; + app.ui.pen_doc = None; + open_path(&mut app); + app.ui.direct_selection.target = Some(crate::direct_select::PathRef::Pen); + app.ui.direct_selection.anchors = vec![[0, 1]]; + app.run("file.new", json!({"width": 120, "height": 90})).unwrap(); + assert_eq!(app.ui.direct_selection, Default::default(), "the anchors went with the path"); + } + + #[test] + fn pen_overlay_uses_the_view_document_even_during_direct_selection() { + let mut app = app(); + app.ui.tool = Tool::Pen; + pen_down(&mut app, 20.0, 20.0, false); + pen_up(&mut app); + pen_down(&mut app, 80.0, 60.0, false); + pen_up(&mut app); + let owner = app.session.active().unwrap().doc.clone(); + app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); + let other = app.session.active().unwrap().doc.clone(); + let xf = + ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0)), zoom: 1.0, center: [100.0, 100.0], flip: false, rotation: 0.0, aspect: 1.0 }; + let shapes = |app: &PhotocraftApp, doc: &Document, modifiers| { + let ctx = egui::Context::default(); + let mut output = ctx.run_ui(egui::RawInput { events: vec![egui::Event::ModifiersChanged(modifiers)], ..Default::default() }, |ui| { + draw_overlay(app, ui.painter(), &xf, doc) + }); + output.textures_delta.clear(); + output.shapes.len() + }; + for active in [0, 1] { + assert!(app.session.set_active(active)); + for tool in [Tool::Pen, Tool::DirectSelection] { + app.ui.tool = tool; + for modifiers in [egui::Modifiers::NONE, egui::Modifiers::COMMAND] { + for (doc, visible) in [(&owner, true), (&other, false)] { + let with_pen = shapes(&app, doc, modifiers); + let pen = app.ui.pen.take(); + let without = shapes(&app, doc, modifiers); + app.ui.pen = pen; + assert_eq!(with_pen > without, visible, "active {active}, {tool:?}, {modifiers:?}"); + } + } + } + } + } + + #[test] + fn failed_stale_pen_commit_preserves_the_draft_until_retry() { + for action in ["pen", "curvature", "undo", "commit", "command"] { + let mut app = app(); + app.run("path.set", json!({"name": "work", "path": {"subpaths": [{"closed": false, "knots": [[5, 5], [10, 10]]}]}})).unwrap(); + let original = app.session.active().unwrap().doc.work_path.clone().unwrap(); + app.ui.tool = Tool::Pen; + for (x, y) in [(20.0, 20.0), (80.0, 60.0)] { + pen_down(&mut app, x, y, false); + pen_up(&mut app); + } + let draft = app.ui.pen.clone(); + let owner = app.ui.pen_doc; + app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.ui.tool_options.vector_mode = "shape".into(); + app.ui.selected_path = Some("foreign named path".into()); + app.session.authorize = Some(|_, _| Err(photocraft_engine::EngineError::Other("test rejection".into()))); + match action { + "pen" => pen_down(&mut app, 150.0, 150.0, false), + "curvature" => curvature_down(&mut app, 150.0, 150.0), + "undo" => assert!(!pen_undo_last_point(&mut app)), + "commit" => pen_commit(&mut app, true), + _ => { + assert!(app.run("file.close", json!({})).is_err()); + } + } + assert_eq!(app.ui.pen, draft, "{action}: failed save retains all anchors"); + assert_eq!(app.ui.pen_doc, owner); + assert_eq!(app.session.active_index(), Some(1)); + assert_eq!(app.session.documents()[0].doc.work_path.as_ref(), Some(&original)); + assert!(app.session.documents()[1].doc.work_path.is_none()); + assert!(app.ui.status_error); + assert!(!crate::direct_select::candidates(&app).iter().any(|(target, _)| *target == crate::direct_select::PathRef::Pen)); + app.session.authorize = None; + app.sync_views(); + assert!(app.ui.pen.is_none()); + assert_eq!(app.session.active_index(), Some(1)); + assert_eq!(app.ui.selected_path.as_deref(), Some("foreign named path")); + let saved = app.session.documents()[0].doc.work_path.clone().unwrap(); + assert_eq!(saved.subpaths.len(), original.subpaths.len() + 1); + assert_eq!(saved.subpaths[0], original.subpaths[0]); + app.sync_views(); + assert_eq!(app.session.documents()[0].doc.work_path.as_ref(), Some(&saved), "retry never duplicates the path"); + assert!(app.session.set_active(0)); + assert!(app.session.undo()); + assert_eq!(app.session.active().unwrap().doc.work_path.as_ref(), Some(&original)); + } + } + + #[test] + fn new_document_reports_a_failed_work_path_save_and_retains_the_draft() { + let mut app = app(); + pen_down(&mut app, 20.0, 20.0, false); + pen_up(&mut app); + pen_down(&mut app, 80.0, 60.0, false); + pen_up(&mut app); + let draft = app.ui.pen.clone(); + app.session.authorize = Some(|id, _| if id == "path.set" { Err(photocraft_engine::EngineError::Other("test rejection".into())) } else { Ok(()) }); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + assert!(app.ui.status_error); + assert!(app.ui.status.contains("test rejection")); + assert_eq!(app.ui.pen, draft); + assert_eq!(app.session.active_index(), Some(1)); + assert!(app.session.documents().iter().all(|d| d.doc.work_path.is_none())); + app.session.authorize = None; + app.sync_views(); + assert!(app.ui.pen.is_none()); + assert!(app.session.documents()[0].doc.work_path.is_some()); + assert!(app.session.documents()[1].doc.work_path.is_none()); + } + + #[test] + fn new_document_preserves_a_single_pen_anchor_as_a_work_path() { + for tool in [Tool::Pen, Tool::CurvaturePen] { + let mut app = app(); + app.ui.tool = tool; + if tool == Tool::Pen { + pen_down(&mut app, 20.0, 30.0, false); + pen_up(&mut app); + } else { + curvature_down(&mut app, 20.0, 30.0); + curvature_up(&mut app); + } + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + assert!(app.ui.pen.is_none()); + let path = app.session.documents()[0].doc.work_path.as_ref().unwrap(); + assert_eq!(path.subpaths[0].knots.len(), 1); + assert_eq!(path.subpaths[0].knots[0].anchor, photocraft_geom::Point::new(20.0, 30.0)); + assert!(app.session.active().unwrap().doc.work_path.is_none()); + } + } + + #[test] + fn stale_pen_commits_to_its_owner_across_document_changes() { + use photocraft_doc::{ColorMode, SampleType, Size}; + for tool in [Tool::Pen, Tool::CurvaturePen] { + for action in ["switch", "open", "closeOther", "pen", "curvature", "undo", "commit"] { + let mut app = app(); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.ui.tool = tool; + for (x, y) in [(20.0, 20.0), (80.0, 60.0), (120.0, 30.0)] { + if tool == Tool::Pen { + pen_down(&mut app, x, y, false); + pen_up(&mut app); + } else { + curvature_down(&mut app, x, y); + curvature_up(&mut app); + } + } + let owner = app.ui.pen_doc.unwrap(); + let expected = photocraft_engine::vector_cmds::parse_path(&pen_to_json(app.ui.pen.as_ref().unwrap(), false)).unwrap(); + app.ui.direct_selection.target = Some(crate::direct_select::PathRef::Pen); + app.ui.direct_selection.anchors = vec![[0, 1]]; + assert!(app.session.set_active(0)); + match action { + "open" => { + app.services.import = + Some(Box::new(|name, _, _| Ok((Document::new(name, Size::new(4, 4), ColorMode::Rgb, SampleType::U8), Vec::new())))); + app.open_file("synthetic.png", &[]).unwrap(); + } + "closeOther" => { + app.run("file.close", json!({"document": 0})).unwrap(); + } + "pen" => pen_down(&mut app, 150.0, 150.0, false), + "curvature" => curvature_down(&mut app, 150.0, 150.0), + "undo" => assert!(!pen_undo_last_point(&mut app)), + "commit" => pen_commit(&mut app, true), + _ => app.sync_views(), + } + let source = app.session.documents().iter().find(|d| d.doc.id == owner).unwrap(); + let path = source.doc.work_path.as_ref().expect("the owner keeps the unfinished path"); + assert_eq!(path, &expected, "{tool:?} {action}"); + assert!(app.session.documents().iter().filter(|d| d.doc.id != owner).all(|d| d.doc.work_path.is_none())); + if action != "closeOther" { + assert_ne!(app.session.active().unwrap().doc.id, owner, "the active document is restored"); + } + assert_eq!(app.ui.direct_selection, Default::default()); + let index = app.session.documents().iter().position(|d| d.doc.id == owner).unwrap(); + assert!(app.session.set_active(index)); + assert!(app.session.undo()); + assert!(app.session.active().unwrap().doc.work_path.is_none()); + assert!(app.session.redo()); + assert!(app.session.active().unwrap().doc.work_path.is_some()); + } + } + } + /// #4721: a second Pen path joins the Work Path it was drawn after (still selected), as a new /// subpath, instead of replacing it; a saved path that is selected takes it instead; with no /// path selected, the new path replaces the Work Path, as in Photoshop. diff --git a/crates/vector/src/shapes.rs b/crates/vector/src/shapes.rs index b154a47325..dfeae26e81 100644 --- a/crates/vector/src/shapes.rs +++ b/crates/vector/src/shapes.rs @@ -111,6 +111,101 @@ pub fn polygon(x: f64, y: f64, w: f64, h: f64, sides: u32, star_ratio: f64) -> P Path::new(vec![Subpath::polygon(&pts)]) } +/// A rounded vertex: see [`fillet`]. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Fillet { + /// The arc's centre and its tangent points on the edge to the previous and the next vertex. + pub centre: [f64; 2], + pub from: [f64; 2], + pub to: [f64; 2], + /// The radius used (the one asked for, capped at what the edges allow). + pub radius: f64, + /// Unit direction from the vertex towards the arc's centre, and half the vertex's angle. + pub bisector: [f64; 2], + pub half_angle: f64, +} + +/// Where a vertex's rounding sits: the vertex `v` between neighbours `prev` and `next`, rounded +/// with radius `r`, capped so its tangent points stay within half of each edge (two neighbouring +/// roundings never overlap). `None` for `r <= 0` or a degenerate vertex (a zero-length edge, or +/// a straight or folded angle). +pub fn fillet(prev: [f64; 2], v: [f64; 2], next: [f64; 2], r: f64) -> Option { + let unit = |a: [f64; 2], b: [f64; 2]| { + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + let l = dx.hypot(dy); + (l > 1e-9).then(|| ([dx / l, dy / l], l)) + }; + if r.is_nan() || r <= 0.0 { + return None; + } + let (u1, l1) = unit(v, prev)?; + let (u2, l2) = unit(v, next)?; + let cos = (u1[0] * u2[0] + u1[1] * u2[1]).clamp(-1.0, 1.0); + let half = cos.acos() / 2.0; + if half < 1e-6 || half > std::f64::consts::FRAC_PI_2 - 1e-6 { + return None; + } + let r = r.min(l1.min(l2) / 2.0 * half.tan()); + let t = r / half.tan(); + let (bx, by) = (u1[0] + u2[0], u1[1] + u2[1]); + let bl = bx.hypot(by); + if bl < 1e-9 { + return None; + } + let bisector = [bx / bl, by / bl]; + let c = r / half.sin(); + Some(Fillet { + centre: [v[0] + bisector[0] * c, v[1] + bisector[1] * c], + from: [v[0] + u1[0] * t, v[1] + u1[1] * t], + to: [v[0] + u2[0] * t, v[1] + u2[1] * t], + radius: r, + bisector, + half_angle: half, + }) +} + +/// `path` (closed subpaths of corner knots, as [`polygon`] makes) with every vertex rounded by a +/// circular arc of radius `r` tangent to both its edges, each capped as [`fillet`] does, like +/// Photoshop's rounded triangles and polygons. `r <= 0` leaves it sharp. +pub fn round_corners(path: Path, r: f64) -> Path { + if r.is_nan() || r <= 0.0 { + return path; + } + let subpaths = path + .subpaths + .into_iter() + .map(|sp| { + let n = sp.knots.len(); + if n < 3 || !sp.closed { + return sp; + } + let pt = |i: usize| sp.knots.get(i % n).map_or([0.0; 2], |k| [k.anchor.x, k.anchor.y]); + let mut knots = Vec::with_capacity(n * 2); + for i in 0..n { + let (prev, v, next) = (pt(i + n - 1), pt(i), pt(i + 1)); + let Some(f) = fillet(prev, v, next, r) else { + knots.push(Knot::corner(v[0], v[1])); + continue; + }; + // The arc as one cubic: handles 4/3 tan(sweep/4) r long, along the tangents + // towards the vertex; the sweep is pi minus the vertex's angle. + let sweep = std::f64::consts::PI - 2.0 * f.half_angle; + let h = 4.0 / 3.0 * (sweep / 4.0).tan() * f.radius; + let toward = |a: [f64; 2]| { + let (dx, dy) = (v[0] - a[0], v[1] - a[1]); + let l = dx.hypot(dy).max(1e-12); + Point::new(a[0] + dx / l * h, a[1] + dy / l * h) + }; + let (from, to) = (Point::new(f.from[0], f.from[1]), Point::new(f.to[0], f.to[1])); + knots.push(Knot { anchor: from, in_ctrl: from, out_ctrl: toward(f.from), smooth: false }); + knots.push(Knot { anchor: to, in_ctrl: toward(f.to), out_ctrl: to, smooth: false }); + } + Subpath { knots, ..sp } + }) + .collect(); + Path { subpaths, ..path } +} + /// A line drawn as a filled bar of `weight` px from `a` to `b`. pub fn line(a: (f64, f64), b: (f64, f64), weight: f64) -> Path { let (dx, dy) = (b.0 - a.0, b.1 - a.1); @@ -125,7 +220,9 @@ pub fn live_path(s: &LiveShape) -> Path { match s { LiveShape::Rect { rect: [x, y, w, h], radii } => rounded_rect(*x, *y, *w, *h, *radii), LiveShape::Ellipse { rect: [x, y, w, h] } => ellipse(*x, *y, *w, *h), - LiveShape::Polygon { rect: [x, y, w, h], sides, star_ratio } => polygon(*x, *y, *w, *h, *sides, *star_ratio), + LiveShape::Polygon { rect: [x, y, w, h], sides, star_ratio, corner_radius } => { + round_corners(polygon(*x, *y, *w, *h, *sides, *star_ratio), *corner_radius) + } LiveShape::Line { from, to, weight } => line((from[0], from[1]), (to[0], to[1]), *weight), } } @@ -134,6 +231,59 @@ pub fn live_path(s: &LiveShape) -> Path { mod tests { use super::*; + /// The point at `t` of the cubic leaving `a` towards `b`. + fn at(a: &Knot, b: &Knot, t: f64) -> [f64; 2] { + let u = 1.0 - t; + let w = [u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t]; + let p = [a.anchor, a.out_ctrl, b.in_ctrl, b.anchor]; + [p.iter().zip(w).map(|(p, w)| p.x * w).sum(), p.iter().zip(w).map(|(p, w)| p.y * w).sum()] + } + + #[test] + fn a_rounded_triangle_has_one_arc_per_vertex_on_a_true_circle() { + let sharp = polygon(0.0, 0.0, 300.0, 300.0, 3, 1.0); + assert_eq!(sharp.subpaths[0].knots.len(), 3); + let p = round_corners(sharp.clone(), 20.0); + let k = &p.subpaths[0].knots; + assert_eq!(k.len(), 6, "two knots per rounded vertex"); + // Each arc stays 20 px from its centre along its length (one cubic, within 0.1 px). + let verts: Vec<[f64; 2]> = sharp.subpaths[0].knots.iter().map(|k| [k.anchor.x, k.anchor.y]).collect(); + for i in 0..3 { + let f = fillet(verts[(i + 2) % 3], verts[i], verts[(i + 1) % 3], 20.0).unwrap(); + assert!((f.radius - 20.0).abs() < 1e-9); + assert_eq!([k[2 * i].anchor.x, k[2 * i].anchor.y], f.from); + for t in [0.25, 0.5, 0.75] { + let q = at(&k[2 * i], &k[2 * i + 1], t); + let d = (q[0] - f.centre[0]).hypot(q[1] - f.centre[1]); + assert!((d - 20.0).abs() < 0.1, "vertex {i} t {t}: {d}"); + } + } + // The edges between the arcs stay straight. + assert_eq!((k[1].out_ctrl, k[2].in_ctrl), (k[1].anchor, k[2].anchor)); + } + + #[test] + fn huge_radii_are_capped_and_zero_stays_sharp() { + let sharp = polygon(0.0, 0.0, 100.0, 100.0, 6, 1.0); + assert_eq!(round_corners(sharp.clone(), 0.0), sharp); + assert_eq!(round_corners(sharp.clone(), f64::NAN), sharp); + let p = round_corners(sharp, 1.0e9); + // Capped: the tangent points of neighbouring vertices meet halfway along each edge. + let k = &p.subpaths[0].knots; + assert_eq!(k.len(), 12); + for w in k.windows(2).skip(1).step_by(2) { + let d = (w[0].anchor.x - w[1].anchor.x).hypot(w[0].anchor.y - w[1].anchor.y); + assert!(d < 1e-6, "no straight left between arcs: {d}"); + } + assert!(k.iter().all(|k| k.anchor.x.is_finite() && k.anchor.y.is_finite())); + } + + #[test] + fn a_star_rounds_its_inner_vertices_too() { + let p = live_path(&LiveShape::Polygon { rect: [0.0, 0.0, 200.0, 200.0], sides: 5, star_ratio: 0.5, corner_radius: 6.0 }); + assert_eq!(p.subpaths[0].knots.len(), 20); + } + #[test] fn rounded_rect_knots() { let p = rounded_rect(0.0, 0.0, 100.0, 50.0, [10.0; 4]); diff --git a/docs/gaps.md b/docs/gaps.md index 3cfbc47f43..0510221595 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19; G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19; G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -163,6 +163,7 @@ what an agent can't finish alone. exist (GrabCut/max-flow selection, Remove tool by non-local patch completion). - **Evidence:** Photoshop's `Required/UXP` ships the Neural Filters gallery and its `.kys` has a Neural Filters taskspace; users ask for local models (#2313, #1996, #2682). +- **Progress (2026-10-17):** classical Object Finder no longer rejects a foreground solely for covering over 60% of the canvas (#6548); canvas-boundary coverage distinguishes it from the surrounding backdrop. This does not add semantic recognition or close #2788. - **Estimate:** 300–600 h. **Human:** owner decision #41 (model licences, local vs remote). ### G16. Plug-in compatibility @@ -318,6 +319,7 @@ what an agent can't finish alone. | 2026-10-20 | minor | G8: Auto / Left-to-right / Right-to-left paragraph direction buttons in the Paragraph panel, Properties and the Type options bar; TYPE-218-2 partial (the visual RTL caret is still missing) | | 2026-10-19 | minor | G10: JPEG Options dialog landed (FILE-215-19); no readiness percentage or estimate changed | | 2026-10-18 | minor | G13: Russian gained the 1,229 dynamic UI labels other catalogs already had (filter and adjustment parameters, Camera Raw, lighting, History/Layers/paths menus, metadata, System Info, status templates, dialogs); engine messages and native review remain open | +| 2026-10-17 | minor | Classical Object Finder improvement; learned segmentation remains open | | 2026-10-15 | minor | G10: Cineon write landed (10-bit log gray/RGB, Save As `.cin`) | | 2026-10-15 | minor | G1 / #5972 A1: render only provably constant-RGB `GdFl` noise fills; variable noise synthesis remains open | | 2026-10-18 | minor | G9: with no selection, Copy takes fill and adjustment layers whole and Paste recreates them (#2344); UI-217-23 stays partial (a single pixel layer copies its pixels only); estimate unchanged | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 86c93dd1c7..3f074925f2 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity: tools, handles, modifiers and feel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests) · **Target:** Adobe Photoshop 2026 How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation, @@ -29,7 +29,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Move, artboard | Move, Artboard | | | Marquee | Rectangular, Elliptical, Single Row, Single Column | | | Lasso | Lasso, Polygonal, Magnetic | | -| Selection | Object Selection, Quick Selection, Magic Wand | Object Selection's Object Finder (on by default in its options bar): resting the pointer highlights the object a click selects (`select.object` with `point`; ⇧ adds, ⌥ subtracts, ⇧⌥ intersects); dragging keeps the rectangle mode (#4087). Heuristic, not a learned segmenter (#2788). | +| Selection | Object Selection, Quick Selection, Magic Wand | Object Selection's Object Finder (on by default in its options bar): resting the pointer highlights the object a click selects (`select.object` with `point`; ⇧ adds, ⌥ subtracts, ⇧⌥ intersects); dragging keeps the rectangle mode (#4087). Where the active layer has no pixels under the pointer (Sample All Layers off), the status bar says so instead of showing nothing (#6549). Dominant enclosed objects are retained; regions covering most of the canvas boundary are treated as background (#6548). Heuristic, not a learned segmenter (#2788). | | Crop and slice | Crop, Perspective Crop, Slice, Slice Select | | | Frame | Frame (frames are groups clipped by a rectangular or elliptical mask) | | | Eyedropper | Eyedropper, Color Sampler, Ruler, Note, Count | | @@ -128,7 +128,9 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Path operations in the options bar (combine, subtract, intersect, exclude) | partial | `layer.combineShapes.*`; Pen options #1397 | | On-canvas corner-radius handles on live shapes | done: every selected live rectangle shows a widget at each arc centre with Path Selection, the Rectangle tool and Move with Show Transform Controls; a drag rounds every corner of every selected rectangle to the same radius, ⌥ that corner on each, with the corner radius cursor and an `R:` readout, previewed and committed as one history step (`corner_radius.rs`, `corner_radius_tests.rs`) | TOOL-214-12 | | Shape Density and Feather (Properties › Masks: a shape's path is its vector mask) | done: Density and Feather sliders for a selected shape layer, one undo step per drag, through `layer.vectorMask.edit`; the fill shows beyond the path at 100 − density %, a feather is the vector mask's Gaussian (Photoshop's 10 px ramp within 1 level); fill and stroke stay editable, the stroke stays crisp; PSD mask parameters and .pcraft round trip (#5846) | TOOL-214-12 | +| On-canvas corner-radius handles on live shapes | done: every selected live rectangle, triangle, polygon and star shows a widget at each arc centre with Path Selection, the Rectangle, Triangle and Polygon tools and Move with Show Transform Controls; a drag rounds every corner of every selected shape to the same radius, ⌥ one corner of a rectangle (a triangle or polygon has one radius), with the corner radius cursor and an `R:` readout, previewed and committed as one history step (`corner_radius.rs`, `corner_radius_tests.rs`) | TOOL-214-12 | | Shape stroke controls (dash, caps, joins, align) | done | TOOL-1053-1, [`shape-strokes.md`](shape-strokes.md) | +| Shape options-bar Fill/Stroke hex input | done | #6176; `vector_ui.rs`, `vector_paint_tests.rs`: typed and pasted hex, partial/invalid input, alpha retention, next-shape defaults and selected-shape Undo | | Rotated shape selection hints correct | partial | #2512 | | Curved shape bounds exact | partial | one pixel too wide (#2537) | @@ -238,8 +240,10 @@ Paragraph; the rest open as in-app floating windows. |---|---|---| | 2026-10-19 | minor | A guide being moved or dragged out of a ruler shows an `X:` / `Y:` position readout beside the pointer, from the zero point in the ruler unit (#7680) | | 2026-10-17 | minor | The wheel steps value fields: a focused field takes it wherever the pointer is (the canvas and panels don't scroll), an unfocused one only a gesture that starts over it (#5473, #4061); no percentage changed | +| 2026-10-17 | minor | Object Finder foreground/background discrimination (#6548) | | 2026-10-16 | minor | Hand tool ⌘ / ⌥ held are temporary Zoom In / Zoom Out (#6434); Scroll All Windows and Bird's Eye View rows added as missing | | 2026-10-16 | minor | Type options bar: dragging the "T" icon scrubs the font size, as the Character panel's icons do (#6437) | +| 2026-10-16 | minor | Shape Fill/Stroke compact pickers accept six-digit hex RGB, including pasted values with `#`, retaining alpha and high-depth paint until an edit; typed/pasted colour and history regressions (#6176). Scores unchanged. | | 2026-10-15 | minor | Shape Density and Feather row added as done (TOOL-214-12, #5846) | | 2026-10-13 | minor | Menu bar from the keyboard: Alt / F10 focus, Alt+letter, underlined mnemonics (#4988) | | 2026-10-13 | minor | Layers panel filter row: Kind is a real dropdown with Kind, Name and Mode filters, drivable by `ui.set {layerFilter}` (#5048); row added as partial | @@ -280,3 +284,4 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-20 | minor | Free Transform of an artboard child omits ancestor paper from the covering and isolated composites while retaining board clipping. The base canvas and whole-artboard backgrounds are preserved; synthetic regressions in `transform_tool.rs`. No parity percentage changed. | | 2026-10-18 | minor | Move tool: dragging selected pixels shrinks the marching ants to the moved pixels as the drag starts, and the dropped selection is theirs, as in Photoshop (#7538) | | 2026-10-15 | minor | ⌫/Delete with a smart filter row selected removes that filter (the Smart Filters row: all of them), as the Delete button does, instead of the smart object (#4751) | +| 2026-10-14 | minor | Rounded corners on live triangles, polygons and stars: Properties field and on-canvas widgets (TOOL-214-12) | diff --git a/scorecard/tools.toml b/scorecard/tools.toml index 578deb2df9..6a1ac772f7 100644 --- a/scorecard/tools.toml +++ b/scorecard/tools.toml @@ -192,7 +192,7 @@ id = "TOOL-214-12" target = "Shapes: on-canvas corner radius handles, shape feather, selection to shape with tolerance" status = "done" issue = 214 -note = "On-canvas corner radius widgets on every selected live rectangle with Path Selection, the Rectangle tool and Move + Show Transform Controls: drag rounds every corner of every selected rectangle alike, Alt that corner on each, corner radius cursor and R: readout, one history step (corner_radius.rs, 2026-10-13); select.convertToShape with tolerance (vector_cmds.rs:1256); shape Density and Feather (Properties, layer.vectorMask.edit on a shape layer; the path as the fill's soft vector mask, Photoshop's 10 px feather ramp within 1 level; PSD mask parameters and .pcraft, #5846, 2026-10-15)" +note = "On-canvas corner radius widgets on every selected live rectangle, triangle, polygon and star (LiveShape::Polygon corner_radius, shape.create/edit cornerRadius, vector shapes::round_corners) with Path Selection, the Rectangle/Triangle/Polygon tools and Move + Show Transform Controls: drag rounds every corner of every selected shape alike, Alt one corner of a rectangle, corner radius cursor and R: readout, one history step (corner_radius.rs, 2026-10-13); select.convertToShape with tolerance (vector_cmds.rs:1256); shape Density and Feather (Properties, layer.vectorMask.edit on a shape layer; the path as the fill's soft vector mask, Photoshop's 10 px feather ramp within 1 level; PSD mask parameters and .pcraft, #5846, 2026-10-15)" [[item]] id = "TOOL-214-13"