From 24be8ea6e2b117929ad2f168382a379e0b07e64a Mon Sep 17 00:00:00 2001 From: Gabriel Heuser Date: Mon, 2 Nov 2026 01:46:05 +0200 Subject: [PATCH] Add editable arrowheads to Line tool (#6408) (#9259) * Add configurable arrowheads to line tool * Add editable arrowheads to Line tool * Fix arrow scaling validation and PSD import defaults * Preserve arrow geometry under affine transforms * Preserve scaled arrowhead dimensions and prevent non-finite geometry * Localize arrowhead Start and End labels * Fix arrow transform editability and short line handling * Fix PSD arrow concavity serialization * Fix arrow dimension fallback validation * Refine arrowhead options UI to match Photoshop * Add opt-in arrowhead defaults to Shape properties * Fix tool reset syntax after upstream merge * Validate arrowhead flags and clamp copied defaults * Preserve editable headless lines under transforms * Fix arrowhead UI imports and formatting after v0.17 merge * Format arrowhead properties after v0.18 merge * Fix Clippy lint errors in arrow geometry and tests * Fix headless arrow geometry after nonuniform scaling * Fix proportional arrow scaling in Properties panel * Fix Rust formatting in Properties arrow scaling tests * test: validate invalid arrowhead dimensions without mutation * fix: preserve arrow compatibility with older pcraft versions --- crates/doc/src/vector.rs | 11 + crates/engine/src/cmd/vector_cmds.rs | 4 +- crates/engine/src/cmd/vector_cmds/params.rs | 48 +++- crates/engine/src/cmd/vector_cmds/shape.rs | 270 +++++++++++++++++- crates/engine/src/cmd/vector_cmds/tests.rs | 144 ++++++++++ crates/format/src/convert.rs | 15 +- crates/format/src/manifest.rs | 4 + crates/format/tests/it/roundtrip.rs | 54 ++++ crates/io/src/vector_map.rs | 41 ++- crates/ui-egui/src/panels/layer_props_ui.rs | 74 ++++- crates/ui-egui/src/panels/options_bar.rs | 1 + crates/ui-egui/src/state/tool_options.rs | 11 + crates/ui-egui/src/tools/corner_radius.rs | 2 +- crates/ui-egui/src/tools/tool_reset.rs | 18 +- crates/ui-egui/src/tools/vector_ui.rs | 67 +++++ crates/ui-egui/src/tools/vector_ui/options.rs | 98 ++++++- crates/ui-egui/src/tools/vector_ui/overlay.rs | 7 +- crates/ui-egui/src/tools/vector_ui/shapes.rs | 5 +- crates/vector/src/shapes.rs | 119 ++++++++ docs/shape-strokes.md | 13 + 20 files changed, 979 insertions(+), 27 deletions(-) diff --git a/crates/doc/src/vector.rs b/crates/doc/src/vector.rs index a66aed8587..9dc8edf258 100644 --- a/crates/doc/src/vector.rs +++ b/crates/doc/src/vector.rs @@ -318,6 +318,17 @@ pub enum LiveShape { to: [f64; 2], weight: f64, }, + /// Editable vector arrow with parameters preserved by serde in the project document. + ArrowLine { + from: [f64; 2], + to: [f64; 2], + weight: f64, + start: bool, + end: bool, + width: f64, + length: f64, + concavity: f64, + }, } /// A shape layer: a filled and/or stroked path. diff --git a/crates/engine/src/cmd/vector_cmds.rs b/crates/engine/src/cmd/vector_cmds.rs index 5f6b3c301a..f331a30335 100644 --- a/crates/engine/src/cmd/vector_cmds.rs +++ b/crates/engine/src/cmd/vector_cmds.rs @@ -98,7 +98,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),"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}"## + 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),"arrowStart":bool=false,"arrowEnd":bool=false,"arrowWidth":px=52,"arrowLength":px=50,"arrowConcavity":0..100=0 (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 @@ -108,7 +108,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?,"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"## + 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),"arrowStart":bool?,"arrowEnd":bool?,"arrowWidth":px?,"arrowLength":px?,"arrowConcavity":0..100? (line arrows),"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/cmd/vector_cmds/params.rs b/crates/engine/src/cmd/vector_cmds/params.rs index cf73f96a41..bab083c162 100644 --- a/crates/engine/src/cmd/vector_cmds/params.rs +++ b/crates/engine/src/cmd/vector_cmds/params.rs @@ -357,13 +357,51 @@ pub(super) fn live_from_params(cmd: &str, kind: &str, p: &Value, prev: Option<&L } "line" => { let (pf, pto, pw) = match prev { - Some(LiveShape::Line { from, to, weight }) => (Some(*from), Some(*to), *weight), + Some(LiveShape::Line { from, to, weight }) | Some(LiveShape::ArrowLine { from, to, weight, .. }) => (Some(*from), Some(*to), *weight), _ => (None, None, 1.0), }; - LiveShape::Line { - from: nums::<2>(p, "from").or(pf).ok_or_else(|| bad(cmd, "`from`: [x, y] is required"))?, - to: nums::<2>(p, "to").or(pto).ok_or_else(|| bad(cmd, "`to`: [x, y] is required"))?, - weight: f64p(p, "weight").unwrap_or(pw).max(0.0), + let from = nums::<2>(p, "from").or(pf).ok_or_else(|| bad(cmd, "`from`: [x, y] is required"))?; + let to = nums::<2>(p, "to").or(pto).ok_or_else(|| bad(cmd, "`to`: [x, y] is required"))?; + let weight = f64p(p, "weight").unwrap_or(pw).max(0.0); + let arrow_fields = ["arrowStart", "arrowEnd", "arrowWidth", "arrowLength", "arrowConcavity"]; + let is_arrow = matches!(prev, Some(LiveShape::ArrowLine { .. })) || arrow_fields.iter().any(|k| p.get(*k).is_some()); + if is_arrow { + let (ps, pe, pwidth, plen, pc) = match prev { + Some(LiveShape::ArrowLine { start, end, width, length, concavity, .. }) => (*start, *end, *width, *length, *concavity), + _ => (false, false, 52.0, 50.0, 0.0), + }; + let dim = |key: &str, fallback: f64, min: f64, max: f64| -> Result { + let value = match p.get(key) { + Some(v) => v.as_f64().ok_or_else(|| bad(cmd, format!("`{key}` must be numeric")))?, + None => fallback, + }; + // Preserve scaled arrowhead dimensions when editing other properties. + let preserved_scaled_size = p.get(key).is_none() && matches!(key, "arrowWidth" | "arrowLength") && value.is_finite(); + let value = if preserved_scaled_size { value.max(min) } else { value }; + if !value.is_finite() || (!preserved_scaled_size && !(min..=max).contains(&value)) { + return Err(bad(cmd, format!("`{key}` must be within {min}..={max}"))); + } + Ok(value) + }; + // Reject invalid flag types rather than silently using defaults. + let flag = |key: &str, fallback: bool| -> Result { + match p.get(key) { + Some(v) => v.as_bool().ok_or_else(|| bad(cmd, format!("`{key}` must be a boolean"))), + None => Ok(fallback), + } + }; + LiveShape::ArrowLine { + from, + to, + weight, + start: flag("arrowStart", ps)?, + end: flag("arrowEnd", pe)?, + width: dim("arrowWidth", pwidth, 1.0, 4096.0)?, + length: dim("arrowLength", plen, 1.0, 4096.0)?, + concavity: dim("arrowConcavity", pc, 0.0, 100.0)?, + } + } else { + LiveShape::Line { from, to, weight } } } o => return Err(bad(cmd, format!("unknown kind `{o}`"))), diff --git a/crates/engine/src/cmd/vector_cmds/shape.rs b/crates/engine/src/cmd/vector_cmds/shape.rs index 961e767924..33e1bfe3cf 100644 --- a/crates/engine/src/cmd/vector_cmds/shape.rs +++ b/crates/engine/src/cmd/vector_cmds/shape.rs @@ -80,7 +80,7 @@ fn live_kind(l: &LiveShape) -> &'static str { LiveShape::Ellipse { .. } => "ellipse", LiveShape::Polygon { star_ratio, .. } if *star_ratio < 1.0 => "star", LiveShape::Polygon { .. } => "polygon", - LiveShape::Line { .. } => "line", + LiveShape::Line { .. } | LiveShape::ArrowLine { .. } => "line", } } @@ -148,12 +148,61 @@ pub(crate) fn shift_shape(doc: &Document, sh: &mut ShapeLayer, dx: f64, dy: f64) } } +/// Returns arrow-local scales when the transformed axes remain perpendicular. +fn arrow_axis_scales(a: &Affine, from: [f64; 2], to: [f64; 2]) -> Option<(f64, f64)> { + let [m0, m1, m2, m3, _, _] = a.m; + let (dx, dy) = (to[0] - from[0], to[1] - from[1]); + let old_length = dx.hypot(dy); + if !old_length.is_finite() || old_length <= 1e-12 { + return None; + } + let (ux, uy) = (dx / old_length, dy / old_length); + let (ax, ay) = (m0 * ux + m2 * uy, m1 * ux + m3 * uy); + let (px, py) = (-m0 * uy + m2 * ux, -m1 * uy + m3 * ux); + let axial = ax.hypot(ay); + let perpendicular = px.hypot(py); + if !axial.is_finite() || !perpendicular.is_finite() || axial <= 1e-12 || perpendicular <= 1e-12 { + return None; + } + let dot = ax * px + ay * py; + (dot.abs() <= 1e-10 * axial * perpendicular).then_some((axial, perpendicular)) +} + /// 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) { + // Check original arrow dimensions before transforming the path. + // Parameters already clamped by arrow_line cannot faithfully + // regenerate the transformed geometry. + if let Some(LiveShape::ArrowLine { from, to, weight, start, end, width, length, .. }) = sh.live.as_ref() + && (*start || *end) + { + let original_length = (to[0] - from[0]).hypot(to[1] - from[1]); + + if !weight.is_finite() + || *weight < 0.1 + || !width.is_finite() + || *width < 1.0 + || !length.is_finite() + || *length < 1.0 + || !original_length.is_finite() + || original_length < 1.0 + { + sh.live = None; + } + } + sh.path = sh.path.transform(a); let [m0, m1, m2, m3, tx, ty] = a.m; let axis_aligned = m1.abs() < 1e-12 && m2.abs() < 1e-12 && m0 > 0.0 && m3 > 0.0; + // The path has already received the exact affine transform. Drop live arrow + // parameters if later edits would regenerate a different, unskewed head. + if let Some(LiveShape::ArrowLine { from, to, .. }) = sh.live.as_ref() + && (to[0] - from[0]).hypot(to[1] - from[1]) > 1e-12 + && arrow_axis_scales(a, *from, *to).is_none() + { + sh.live = None; + } sh.live = match (sh.live.take(), axis_aligned) { (Some(l), true) => { let r = |r: [f64; 4]| [r[0] * m0 + tx, r[1] * m3 + ty, r[2] * m0, r[3] * m3]; @@ -167,6 +216,22 @@ pub fn transform_shape(sh: &mut ShapeLayer, a: &Affine) { LiveShape::Polygon { rect, sides, star_ratio, corner_radius } => { LiveShape::Polygon { rect: r(rect), sides, star_ratio, corner_radius: corner_radius * m0.min(m3) } } + LiveShape::ArrowLine { from, to, weight, start, end, width, length, concavity } => { + let (dx, dy) = (to[0] - from[0], to[1] - from[1]); + let old_len = dx.hypot(dy); + let new_len = (dx * m0).hypot(dy * m3); + let perpendicular_scale = if old_len > 0.0 && new_len > 0.0 { m0 * m3 * old_len / new_len } else { m0.min(m3) }; + LiveShape::ArrowLine { + from: pt(from), + to: pt(to), + weight: weight * perpendicular_scale, + start, + end, + width: width * perpendicular_scale, + length: length * if old_len > 0.0 { new_len / old_len } else { m0.min(m3) }, + concavity, + } + } 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 @@ -195,11 +260,61 @@ pub fn transform_shape(sh: &mut ShapeLayer, a: &Affine) { } LiveShape::Ellipse { rect } => Some(LiveShape::Ellipse { rect: r(rect) }), LiveShape::Line { from, to, weight } => Some(LiveShape::Line { from: pt(from), to: pt(to), weight: weight * k }), + LiveShape::ArrowLine { from, to, weight, start, end, width, length, concavity } => { + // Flips and quarter-turns can have different axial and perpendicular + // scales; using the smaller axis for both loses arrowhead length. + let (axial_scale, perpendicular_scale) = arrow_axis_scales(a, from, to).unwrap_or((k, k)); + Some(LiveShape::ArrowLine { + from: pt(from), + to: pt(to), + weight: weight * perpendicular_scale, + start, + end, + width: width * perpendicular_scale, + length: length * axial_scale, + concavity, + }) + } _ => None, } } + // Arbitrary rotations and other orthogonal-axis affine transforms also + // preserve arrow geometry and should keep their editable live parameters. + (Some(LiveShape::ArrowLine { from, to, weight, start, end, width, length, concavity }), false) => { + arrow_axis_scales(a, from, to).map(|(axial_scale, perpendicular_scale)| { + let pt = |p: [f64; 2]| [m0 * p[0] + m2 * p[1] + tx, m1 * p[0] + m3 * p[1] + ty]; + LiveShape::ArrowLine { + from: pt(from), + to: pt(to), + weight: weight * perpendicular_scale, + start, + end, + width: width * perpendicular_scale, + length: length * axial_scale, + concavity, + } + }) + } _ => None, }; + // Keep the exact transformed outline. Remove live arrow parameters + // when minimum-size constraints would alter its regenerated geometry. + if let Some(LiveShape::ArrowLine { from, to, weight, start, end, width, length, .. }) = sh.live.as_ref() + && (*start || *end) + { + let line_length = (to[0] - from[0]).hypot(to[1] - from[1]); + if !weight.is_finite() + || *weight < 0.1 + || !width.is_finite() + || *width < 1.0 + || !length.is_finite() + || *length < 1.0 + || !line_length.is_finite() + || line_length < 1.0 + { + sh.live = None; + } + } sh.psd_raw = None; } @@ -360,7 +475,23 @@ pub(super) 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", "cornerRadius", "from", "to", "weight"]; + let geom_keys = [ + "kind", + "rect", + "radii", + "radius", + "sides", + "starRatio", + "cornerRadius", + "from", + "to", + "weight", + "arrowStart", + "arrowEnd", + "arrowWidth", + "arrowLength", + "arrowConcavity", + ]; 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`"))?; @@ -572,3 +703,138 @@ pub(super) fn selection_to_shape(s: &mut Session, p: &Value) -> Result { } shape_create(s, &q) } + +#[cfg(test)] +mod arrow_affine_regression_tests { + use super::*; + + #[test] + fn forty_five_degree_rotation_retains_live_arrow_and_geometry() { + let live = + LiveShape::ArrowLine { from: [0.0, 0.0], to: [100.0, 0.0], weight: 8.0, start: false, end: true, width: 31.0, length: 31.0, concavity: 50.0 }; + let mut sh = ShapeLayer { path: vector::shapes::live_path(&live), live: Some(live), ..ShapeLayer::default() }; + let mut a = Affine::translate(0.0, 0.0); + let c = std::f64::consts::FRAC_PI_4.cos(); + let s = std::f64::consts::FRAC_PI_4.sin(); + a.m = [c, s, -s, c, 0.0, 0.0]; + transform_shape(&mut sh, &a); + let Some(LiveShape::ArrowLine { width, length, .. }) = sh.live.as_ref() else { + panic!("Arrow must remain parametrically editable after 45-degree rotation"); + }; + assert!((*width - 31.0).abs() < 1e-9); + assert!((*length - 31.0).abs() < 1e-9); + let rebuilt = vector::shapes::live_path(sh.live.as_ref().unwrap()); + assert_eq!(sh.path.subpaths.len(), rebuilt.subpaths.len()); + for (existing, generated) in sh.path.subpaths.iter().zip(rebuilt.subpaths.iter()) { + assert_eq!(existing.knots.len(), generated.knots.len()); + for (a, b) in existing.knots.iter().zip(generated.knots.iter()) { + assert!((a.anchor.x - b.anchor.x).abs() < 1e-8); + assert!((a.anchor.y - b.anchor.y).abs() < 1e-8); + } + } + } + + #[test] + fn subpixel_scale_keeps_transformed_path_but_drops_unreproducible_live_parameters() { + let live = LiveShape::ArrowLine { from: [0.0, 0.0], to: [100.0, 0.0], weight: 5.0, start: false, end: true, width: 31.0, length: 31.0, concavity: 0.0 }; + let mut sh = ShapeLayer { path: vector::shapes::live_path(&live), live: Some(live), ..ShapeLayer::default() }; + let mut a = Affine::translate(0.0, 0.0); + a.m = [0.01, 0.0, 0.0, 0.01, 0.0, 0.0]; + + let expected_path = sh.path.transform(&a); + transform_shape(&mut sh, &a); + + assert_eq!(sh.path, expected_path); + assert!(sh.live.is_none()); + } + + #[test] + fn headless_arrow_survives_nonuniform_diagonal_scale() { + let live = + LiveShape::ArrowLine { from: [0.0, 0.0], to: [100.0, 100.0], weight: 5.0, start: false, end: false, width: 52.0, length: 50.0, concavity: 0.0 }; + + let mut sh = ShapeLayer { path: vector::shapes::live_path(&live), live: Some(live), ..ShapeLayer::default() }; + + let mut a = Affine::translate(0.0, 0.0); + a.m = [2.0, 0.0, 0.0, 1.0, 0.0, 0.0]; + + let expected_path = sh.path.transform(&a); + transform_shape(&mut sh, &a); + + assert_eq!(sh.path, expected_path); + assert!(sh.live.is_none()); + } + + #[test] + fn zero_length_headless_arrow_survives_scale() { + let live = LiveShape::ArrowLine { from: [0.0, 0.0], to: [0.0, 0.0], weight: 5.0, start: false, end: false, width: 52.0, length: 50.0, concavity: 0.0 }; + + let mut sh = ShapeLayer { path: vector::shapes::live_path(&live), live: Some(live), ..ShapeLayer::default() }; + + let mut a = Affine::translate(0.0, 0.0); + a.m = [2.0, 0.0, 0.0, 1.0, 0.0, 0.0]; + + let expected_path = sh.path.transform(&a); + transform_shape(&mut sh, &a); + + assert_eq!(sh.path, expected_path); + assert!(matches!(sh.live, Some(LiveShape::ArrowLine { start: false, end: false, .. }))); + } + + #[test] + fn headless_short_arrow_remains_editable_after_scaling() { + let live = LiveShape::ArrowLine { from: [0.0, 0.0], to: [0.5, 0.0], weight: 0.05, start: false, end: false, width: 31.0, length: 31.0, concavity: 0.0 }; + let mut sh = ShapeLayer { path: vector::shapes::live_path(&live), live: Some(live), ..ShapeLayer::default() }; + + let mut a = Affine::translate(0.0, 0.0); + a.m = [0.5, 0.0, 0.0, 0.5, 0.0, 0.0]; + + let expected = sh.path.transform(&a); + transform_shape(&mut sh, &a); + + assert_eq!(sh.path, expected); + assert!(matches!(sh.live, Some(LiveShape::ArrowLine { start: false, end: false, .. }))); + } + + #[test] + fn scale_above_minimum_still_keeps_live_arrow_editable() { + let live = LiveShape::ArrowLine { from: [0.0, 0.0], to: [100.0, 0.0], weight: 5.0, start: false, end: true, width: 31.0, length: 31.0, concavity: 0.0 }; + let mut sh = ShapeLayer { path: vector::shapes::live_path(&live), live: Some(live), ..ShapeLayer::default() }; + let mut a = Affine::translate(0.0, 0.0); + a.m = [0.5, 0.0, 0.0, 0.5, 0.0, 0.0]; + + transform_shape(&mut sh, &a); + + let Some(LiveShape::ArrowLine { width, length, .. }) = sh.live else { + panic!("Ordinary scaling must preserve editable arrow parameters"); + }; + + assert!((width - 15.5).abs() < 1e-9); + assert!((length - 15.5).abs() < 1e-9); + } + + #[test] + fn reflected_horizontal_arrow_uses_axial_scale_for_head_length() { + let mut a = Affine::translate(0.0, 0.0); + a.m = [-2.0, 0.0, 0.0, 1.0, 0.0, 0.0]; + let (axial, perpendicular) = arrow_axis_scales(&a, [0.0, 0.0], [100.0, 0.0]).unwrap(); + assert!((axial - 2.0).abs() < 1e-10); + assert!((perpendicular - 1.0).abs() < 1e-10); + } + + #[test] + fn anisotropic_diagonal_arrow_cannot_remain_parametric() { + let mut a = Affine::translate(0.0, 0.0); + a.m = [2.0, 0.0, 0.0, 1.0, 0.0, 0.0]; + assert!(arrow_axis_scales(&a, [0.0, 0.0], [100.0, 100.0]).is_none()); + } + + #[test] + fn uniform_scale_preserves_diagonal_arrow_axes() { + let mut a = Affine::translate(0.0, 0.0); + a.m = [2.0, 0.0, 0.0, 2.0, 0.0, 0.0]; + let (axial, perpendicular) = arrow_axis_scales(&a, [0.0, 0.0], [100.0, 100.0]).unwrap(); + assert!((axial - 2.0).abs() < 1e-10); + assert!((perpendicular - 2.0).abs() < 1e-10); + } +} diff --git a/crates/engine/src/cmd/vector_cmds/tests.rs b/crates/engine/src/cmd/vector_cmds/tests.rs index 0b5b89aae9..b659cbd656 100644 --- a/crates/engine/src/cmd/vector_cmds/tests.rs +++ b/crates/engine/src/cmd/vector_cmds/tests.rs @@ -1238,3 +1238,147 @@ fn triangles_and_polygons_take_a_corner_radius_kept_through_edits_and_scaling() 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)); } + +#[test] +fn arrowhead_flags_reject_wrong_types_without_mutation() { + let mut s = session(160, 120, 8); + let id = s + .execute( + "shape.create", + json!({ + "kind": "line", + "from": [10, 20], + "to": [120, 20], + "arrowStart": true, + "arrowEnd": false + }), + ) + .unwrap()["layer"] + .as_u64() + .unwrap(); + + let original_doc = s.active().unwrap().doc.clone(); + let original_history = s.active().unwrap().history.past_len(); + + for key in ["arrowStart", "arrowEnd"] { + for invalid in [json!("true"), json!(null), json!(1), json!([])] { + let mut create = json!({ + "kind": "line", + "from": [10, 40], + "to": [120, 40] + }); + create[key] = invalid.clone(); + + assert!(s.execute("shape.create", create).is_err(), "{key} must reject {invalid}"); + + let mut edit = json!({"layer": id}); + edit[key] = invalid.clone(); + + assert!(s.execute("shape.edit", edit).is_err(), "{key} edit must reject {invalid}"); + + assert_eq!(s.active().unwrap().doc, original_doc); + assert_eq!(s.active().unwrap().history.past_len(), original_history); + } + } + + // Valid boolean values remain accepted. + s.execute( + "shape.edit", + json!({ + "layer": id, + "arrowStart": false, + "arrowEnd": true + }), + ) + .unwrap(); +} + +#[test] +fn arrowhead_dimensions_reject_invalid_values_without_mutation() { + let mut s = session(160, 120, 8); + let id = s + .execute( + "shape.create", + json!({ + "kind": "line", + "from": [10, 20], + "to": [120, 20], + "arrowEnd": true + }), + ) + .unwrap()["layer"] + .as_u64() + .unwrap(); + + let original_doc = s.active().unwrap().doc.clone(); + let original_history = s.active().unwrap().history.past_len(); + + for (key, invalids) in [ + ("arrowWidth", [json!("52"), json!(null), json!([]), json!(0), json!(4097)]), + ("arrowLength", [json!("50"), json!(null), json!([]), json!(0), json!(4097)]), + ("arrowConcavity", [json!("0"), json!(null), json!([]), json!(-1), json!(101)]), + ] { + for invalid in invalids { + let mut create = json!({ + "kind": "line", + "from": [10, 40], + "to": [120, 40] + }); + create[key] = invalid.clone(); + assert!(s.execute("shape.create", create).is_err(), "{key} create must reject {invalid}"); + assert_eq!(s.active().unwrap().doc, original_doc, "{key} create changed document"); + assert_eq!(s.active().unwrap().history.past_len(), original_history, "{key} create changed history"); + + let mut edit = json!({"layer": id}); + edit[key] = invalid.clone(); + assert!(s.execute("shape.edit", edit).is_err(), "{key} edit must reject {invalid}"); + assert_eq!(s.active().unwrap().doc, original_doc, "{key} edit changed document"); + assert_eq!(s.active().unwrap().history.past_len(), original_history, "{key} edit changed history"); + } + } +} + +#[test] +fn headless_diagonal_arrow_preserves_geometry_after_nonuniform_scale() { + let mut s = session(400, 400, 8); + + let created = s + .execute( + "shape.create", + json!({ + "kind": "line", + "from": [40.0, 40.0], + "to": [140.0, 140.0], + "weight": 5.0, + "arrowStart": false, + "arrowEnd": false + }), + ) + .unwrap(); + + let id = created["layer"].as_u64().unwrap(); + + let transformed = s + .execute( + "shape.edit", + json!({ + "layer": id, + "transform": [2.0, 0.0, 0.0, 1.0, 0.0, 0.0] + }), + ) + .unwrap(); + + assert_ne!(transformed["path"], created["path"]); + assert!(transformed["live"].is_null()); + + let before = s.active().unwrap().doc.clone(); + + // Editing live parameters must not regenerate a different shape. + assert!(s.execute("shape.edit", json!({"layer": id, "weight": 8.0}),).is_err()); + + assert_eq!(s.active().unwrap().doc, before); + + let info = s.execute("shape.info", json!({"layer": id})).unwrap(); + + assert_eq!(info["path"], transformed["path"]); +} diff --git a/crates/format/src/convert.rs b/crates/format/src/convert.rs index e408297abe..b5cfda9ad5 100644 --- a/crates/format/src/convert.rs +++ b/crates/format/src/convert.rs @@ -159,7 +159,16 @@ fn layer_m(l: &Layer, sink: &mut dyn Sink) -> LayerM { psd_raw: opt_blob(&s.psd_raw, sink), path: s.path.clone(), stroke: s.stroke.clone(), - live: s.live.clone(), + // Older PhotoCraft versions cannot deserialize ArrowLine. + // The saved path preserves the arrow's visual appearance. + live: match &s.live { + Some(photocraft_doc::LiveShape::ArrowLine { .. }) => None, + other => other.clone(), + }, + arrow_live: match &s.live { + Some(arrow @ photocraft_doc::LiveShape::ArrowLine { .. }) => Some(arrow.clone()), + _ => None, + }, density: s.density, feather: s.feather, }, @@ -480,11 +489,11 @@ impl Loader<'_> { warp: warp.clone(), }) } - ContentM::Shape { fill, cache, psd_raw, path, stroke, live, density, feather } => LayerContent::Shape(ShapeLayer { + ContentM::Shape { fill, cache, psd_raw, path, stroke, live, arrow_live, density, feather } => LayerContent::Shape(ShapeLayer { path: path.clone(), fill: fill.clone(), stroke: stroke.clone(), - live: live.clone(), + live: arrow_live.as_ref().filter(|shape| matches!(shape, photocraft_doc::LiveShape::ArrowLine { .. })).cloned().or_else(|| live.clone()), cache: self.opt_surface(cache)?, psd_raw: self.opt_blob(psd_raw)?, density: *density, diff --git a/crates/format/src/manifest.rs b/crates/format/src/manifest.rs index 768a3f4320..1164bc0a68 100644 --- a/crates/format/src/manifest.rs +++ b/crates/format/src/manifest.rs @@ -300,6 +300,10 @@ pub enum ContentM { stroke: Option, #[serde(default)] live: Option, + /// Arrow parameters stored separately for backward compatibility. + /// Older PhotoCraft versions ignore this optional field. + #[serde(default, skip_serializing_if = "Option::is_none")] + arrow_live: Option, /// The path's density as the fill's mask; older files have none (100 %). #[serde(default = "full_density")] density: f32, diff --git a/crates/format/tests/it/roundtrip.rs b/crates/format/tests/it/roundtrip.rs index 32ef503139..25af8b22a6 100644 --- a/crates/format/tests/it/roundtrip.rs +++ b/crates/format/tests/it/roundtrip.rs @@ -975,3 +975,57 @@ fn locked_stale_preview_does_not_fail_committed_save() { assert!(!dir.join("thumb.png").exists()); std::fs::remove_dir_all(dir).unwrap(); } + +#[test] +fn arrow_document_is_backward_compatible() { + use photocraft_doc::{Fill, LiveShape, Path, ShapeLayer, Subpath}; + + let arrow = + LiveShape::ArrowLine { from: [12.0, 64.0], to: [140.0, 64.0], weight: 16.0, start: false, end: true, width: 52.0, length: 50.0, concavity: 20.0 }; + + let path = Path::new(vec![Subpath::polygon(&[(12.0, 56.0), (100.0, 56.0), (100.0, 44.0), (140.0, 64.0), (100.0, 84.0), (100.0, 72.0), (12.0, 72.0)])]); + + let mut doc = Document::new("Arrow compatibility", photocraft_doc::Size::new(256, 256), ColorMode::Rgb, SampleType::U8); + + doc.layers.push(Layer::new( + "Editable arrow", + LayerContent::Shape(ShapeLayer { + path: path.clone(), + fill: Some(Fill::Solid(photocraft_doc::Color::BLACK)), + live: Some(arrow.clone()), + ..Default::default() + }), + )); + + let bytes = save_to_bytes(&doc, &SaveOptions::default()).unwrap(); + let manifest = read_manifest(&bytes).unwrap(); + let json = serde_json::to_value(&manifest).unwrap(); + let saved = &json["document"]["layers"][0]["content"]; + + assert_eq!(saved["kind"], "shape"); + assert!(saved["live"].is_null()); + assert_eq!(saved["arrow_live"]["kind"], "arrowLine"); + assert_eq!(saved["path"], serde_json::to_value(&path).unwrap()); + + // Simulate an old reader that knows no ArrowLine or arrow_live. + #[derive(serde::Deserialize)] + struct LegacyShape { + live: Option, + path: Path, + } + + #[derive(serde::Deserialize)] + #[serde(tag = "kind", rename_all = "camelCase")] + #[allow(dead_code)] + enum LegacyLive { + Line { from: [f64; 2], to: [f64; 2], weight: f64 }, + } + + let legacy: LegacyShape = serde_json::from_value(saved.clone()).unwrap(); + assert!(legacy.live.is_none()); + assert_eq!(legacy.path, path); + + // The new reader restores full parametric editability. + let loaded = load_from_bytes(&bytes).unwrap(); + assert_eq!(loaded, doc); +} diff --git a/crates/io/src/vector_map.rs b/crates/io/src/vector_map.rs index 621edf47b8..d7667d408b 100644 --- a/crates/io/src/vector_map.rs +++ b/crates/io/src/vector_map.rs @@ -407,11 +407,25 @@ pub fn live_from_vogk(data: &[u8]) -> Option { Some(LiveShape::Rect { rect: rect_of(o)?, radii: [g("topLeft"), g("topRight"), g("bottomRight"), g("bottomLeft")] }) } 5 => Some(LiveShape::Ellipse { rect: rect_of(o)? }), - 4 => Some(LiveShape::Line { - from: point_of(o, "keyOriginLineStart")?, - to: point_of(o, "keyOriginLineEnd")?, - weight: num(o.get("keyOriginLineWeight")).unwrap_or(1.0), - }), + 4 => { + let from = point_of(o, "keyOriginLineStart")?; + let to = point_of(o, "keyOriginLineEnd")?; + let weight = num(o.get("keyOriginLineWeight")).unwrap_or(1.0); + let start = bool_of(o, "keyOriginLineArrowSt"); + let end = bool_of(o, "keyOriginLineArrowEnd"); + let width = num(o.get("keyOriginLineArrWdth")).unwrap_or(0.0); + let length = num(o.get("keyOriginLineArrLngth")).unwrap_or(0.0); + let concavity = num(o.get("keyOriginLineArrConc")).unwrap_or(0.0); + if start || end || width > 0.0 || length > 0.0 { + // Missing or invalid PSD arrow sizes must not prevent later edits. + let width = if width.is_finite() && width >= 1.0 { width } else { 52.0 }; + let length = if length.is_finite() && length >= 1.0 { length } else { 50.0 }; + let concavity = if concavity.is_finite() { concavity.clamp(0.0, 100.0) } else { 0.0 }; + Some(LiveShape::ArrowLine { from, to, weight, start, end, width, length, concavity }) + } else { + Some(LiveShape::Line { from, to, weight }) + } + } _ => None, } } @@ -463,6 +477,20 @@ pub fn vogk_bytes(s: &LiveShape, dpi: f32) -> Option> { .with("keyOriginLineArrLngth", Value::Double(0.0)) .with("keyOriginLineArrConc", Value::Integer(0)) } + LiveShape::ArrowLine { from, to, weight, start, end, width, length, concavity } => { + let pnt = |p: [f64; 2]| Value::Descriptor(Descriptor::new("Pnt ").with("Hrzn", Value::Double(p[0])).with("Vrtc", Value::Double(p[1]))); + let r = [from[0].min(to[0]), from[1].min(to[1]), (from[0] - to[0]).abs(), (from[1] - to[1]).abs()]; + base(4) + .with("keyOriginShapeBBox", bbox(r)) + .with("keyOriginLineEnd", pnt(*to)) + .with("keyOriginLineStart", pnt(*from)) + .with("keyOriginLineWeight", Value::Double(*weight)) + .with("keyOriginLineArrowSt", Value::Boolean(*start)) + .with("keyOriginLineArrowEnd", Value::Boolean(*end)) + .with("keyOriginLineArrWdth", Value::Double(*width)) + .with("keyOriginLineArrLngth", Value::Double(*length)) + .with("keyOriginLineArrConc", Value::Integer(concavity.round() as i32)) + } LiveShape::Polygon { .. } => return None, }; let o = o.with("keyOriginIndex", Value::Integer(0)); @@ -709,6 +737,9 @@ mod tests { LiveShape::Rect { rect: [1.0, 2.0, 30.0, 40.0], radii: [1.0, 2.0, 3.0, 4.0] }, LiveShape::Ellipse { rect: [5.0, 6.0, 7.0, 8.0] }, LiveShape::Line { from: [0.0, 10.0], to: [20.0, 0.0], weight: 3.0 }, + LiveShape::ArrowLine { from: [0.0, 10.0], to: [20.0, 0.0], weight: 3.0, start: false, end: true, width: 31.0, length: 31.0, concavity: 50.0 }, + // Both arrowheads off still needs to retain the adjustable parameters. + LiveShape::ArrowLine { from: [0.0, 10.0], to: [20.0, 0.0], weight: 3.0, start: false, end: false, width: 31.0, length: 31.0, concavity: 0.0 }, ] { let b = vogk_bytes(&s, 72.0).unwrap(); assert_eq!(live_from_vogk(&b), Some(s)); diff --git a/crates/ui-egui/src/panels/layer_props_ui.rs b/crates/ui-egui/src/panels/layer_props_ui.rs index 62f84ecc8a..6f500049bc 100644 --- a/crates/ui-egui/src/panels/layer_props_ui.rs +++ b/crates/ui-egui/src/panels/layer_props_ui.rs @@ -14,11 +14,14 @@ use crate::{PhotocraftApp, widgets}; /// the top-left corner (Photoshop's Properties W/H fields), or `layer.translate` params for X/Y. /// `tol` is the smallest change, in px, that counts as an edit (see [`edit_tolerance`]). pub fn transform_params(layer: u64, b: [f64; 4], w: Option, h: Option, linked: bool, tol: f32) -> Option { - let (bw, bh) = ((b[2] - b[0]) as f32, (b[3] - b[1]) as f32); - if bw <= 0.0 || bh <= 0.0 { + // Keep the transform geometry in f64. Rounding the linked W/H ratio and + // quad corners to f32 can introduce a tiny anisotropic scale, which makes + // a diagonal live ArrowLine lose its editable parameters after resizing. + let (bw, bh) = (b[2] - b[0], b[3] - b[1]); + if !bw.is_finite() || !bh.is_finite() || bw <= 0.0 || bh <= 0.0 { return None; } - let (mut nw, mut nh) = (w.unwrap_or(bw).max(1.0), h.unwrap_or(bh).max(1.0)); + let (mut nw, mut nh) = (w.map(f64::from).unwrap_or(bw).max(1.0), h.map(f64::from).unwrap_or(bh).max(1.0)); if linked { if w.is_some() { nh = (nw * bh / bw).max(1.0); @@ -26,10 +29,10 @@ pub fn transform_params(layer: u64, b: [f64; 4], w: Option, h: Option, nw = (nh * bw / bh).max(1.0); } } - if (nw - bw).abs() < tol && (nh - bh).abs() < tol { + if (nw - bw).abs() < f64::from(tol) && (nh - bh).abs() < f64::from(tol) { return None; } - let (x0, y0) = (b[0] as f32, b[1] as f32); + let (x0, y0) = (b[0], b[1]); let quad = [[x0, y0], [x0 + nw, y0], [x0 + nw, y0 + nh], [x0, y0 + nh]]; Some(json!({"layer": layer, "rect": b, "quad": quad})) } @@ -385,6 +388,67 @@ mod tests { assert_eq!((b.x0, b.y0, b.width(), b.height()), (10, 20, 50, 25)); } + /// Linked W/H edits must generate a truly uniform affine scale, not one + /// rounded independently on each axis to f32 precision. + #[test] + fn linked_transform_preserves_scale_precision() { + let b = [203.6, 171.7, 712.4, 690.3]; + let p = transform_params(7, b, Some(600.0), None, true, 0.05).unwrap(); + let x0 = p["quad"][0][0].as_f64().unwrap(); + let y0 = p["quad"][0][1].as_f64().unwrap(); + let x1 = p["quad"][1][0].as_f64().unwrap(); + let y1 = p["quad"][2][1].as_f64().unwrap(); + let sx = (x1 - x0) / (b[2] - b[0]); + let sy = (y1 - y0) / (b[3] - b[1]); + assert!((sx - sy).abs() < 1e-12, "linked resize produced different scales: {sx} and {sy}"); + } + + /// A diagonal arrow must remain live after a proportional width edit in + /// Properties, while its outline is transformed by the engine as usual. + #[test] + fn linked_properties_resize_keeps_diagonal_arrow_editable() { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 1200, "height": 1200})).unwrap(); + s.execute( + "shape.create", + json!({ + "kind": "line", + "from": [203.6, 171.7], + "to": [712.4, 690.3], + "weight": 20.0, + "arrowStart": false, + "arrowEnd": true, + "arrowWidth": 55.0, + "arrowLength": 85.0, + "arrowConcavity": 50.0 + }), + ) + .unwrap(); + let (id, b) = { + let st = s.active().unwrap(); + let id = st.active_layer.unwrap(); + let layer = st.doc.layer(id).unwrap(); + let LayerContent::Shape(shape) = &layer.content else { panic!("expected shape layer") }; + assert!(matches!(shape.live.as_ref(), Some(photocraft_doc::vector::LiveShape::ArrowLine { .. }))); + let px = photocraft_compose::bounds::content_bounds(layer.surface().unwrap()); + (id, frame(layer, px)) + }; + let p = transform_params(id.0, b, Some(600.0), None, true, 0.05).unwrap(); + s.execute("edit.transform", p).unwrap(); + let layer = s.active().unwrap().doc.layer(id).unwrap(); + let LayerContent::Shape(shape) = &layer.content else { panic!("expected shape layer") }; + assert!( + matches!(shape.live.as_ref(), Some(photocraft_doc::vector::LiveShape::ArrowLine { .. })), + "proportional Properties resize must preserve live arrow settings" + ); + + // The next live-property edit must still be accepted after resizing. + s.execute("shape.edit", json!({"layer": id.0, "arrowConcavity": 60.0})).unwrap(); + let layer = s.active().unwrap().doc.layer(id).unwrap(); + let LayerContent::Shape(shape) = &layer.content else { panic!("expected shape layer") }; + assert!(matches!(shape.live.as_ref(), Some(photocraft_doc::vector::LiveShape::ArrowLine { concavity, .. }) if (*concavity - 60.0).abs() < 1e-9)); + } + /// A stroked shape's Transform fields show its path, not the stroke's pixels, and a W edit /// scales the path to that width, as in Photoshop. #[test] diff --git a/crates/ui-egui/src/panels/options_bar.rs b/crates/ui-egui/src/panels/options_bar.rs index e03fa24e7a..fc67eccaca 100644 --- a/crates/ui-egui/src/panels/options_bar.rs +++ b/crates/ui-egui/src/panels/options_bar.rs @@ -40,6 +40,7 @@ pub(super) fn options_bar_height(pro: bool, narrow: bool) -> f32 { } pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + crate::vector_ui::track_arrow_default_selection(app, ui.ctx()); let t = Tokens::get(ui.ctx()); let narrow = app.session.prefs().workspace.narrow_options_bar(); egui::Panel::top("options_bar") diff --git a/crates/ui-egui/src/state/tool_options.rs b/crates/ui-egui/src/state/tool_options.rs index d577475103..bf5303e0d5 100644 --- a/crates/ui-egui/src/state/tool_options.rs +++ b/crates/ui-egui/src/state/tool_options.rs @@ -109,6 +109,12 @@ pub struct ToolOptions { pub corner_radius: f32, pub polygon_sides: u32, pub line_weight: f32, + /// Line arrowheads. + pub arrow_start: bool, + pub arrow_end: bool, + pub arrow_width: f32, + pub arrow_length: f32, + pub arrow_concavity: f32, /// Freeform Pen › Curve Fit: how far (px) the fitted path may stray from the pointer trace. #[serde(default = "default_curve_fit")] pub curve_fit: f32, @@ -423,6 +429,11 @@ impl Default for ToolOptions { corner_radius: 0.0, polygon_sides: 5, line_weight: 3.0, + arrow_start: false, + arrow_end: false, + arrow_width: 52.0, + arrow_length: 50.0, + arrow_concavity: 0.0, curve_fit: default_curve_fit(), pen_auto_add_delete: true, marquee_style: default_marquee_style(), diff --git a/crates/ui-egui/src/tools/corner_radius.rs b/crates/ui-egui/src/tools/corner_radius.rs index a5199b6c9d..6358ffd139 100644 --- a/crates/ui-egui/src/tools/corner_radius.rs +++ b/crates/ui-egui/src/tools/corner_radius.rs @@ -178,7 +178,7 @@ fn target(layer: u64, live: &LiveShape) -> Option { 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, + LiveShape::Ellipse { .. } | LiveShape::Line { .. } | LiveShape::ArrowLine { .. } => None, } } diff --git a/crates/ui-egui/src/tools/tool_reset.rs b/crates/ui-egui/src/tools/tool_reset.rs index 701914b0b9..4a6ee2c2d8 100644 --- a/crates/ui-egui/src/tools/tool_reset.rs +++ b/crates/ui-egui/src/tools/tool_reset.rs @@ -211,7 +211,23 @@ pub fn option_keys(tool: Tool) -> &'static [&'static str] { Tool::Pen => reset!(vector_mode, pen_path_op, pen_auto_add_delete), Tool::CurvaturePen => reset!(vector_mode, pen_path_op), Tool::FreeformPen => reset!(vector_mode, pen_path_op, curve_fit), - Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::Line | Tool::CustomShape => { + Tool::Line => reset!( + vector_mode, + shape_fill, + stroke_width, + shape_stroke, + shape_fill_gradient, + shape_stroke_gradient, + corner_radius, + polygon_sides, + line_weight, + arrow_start, + arrow_end, + arrow_width, + arrow_length, + arrow_concavity, + ), + Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::CustomShape => { reset!(vector_mode, shape_fill, stroke_width, shape_stroke, shape_fill_gradient, shape_stroke_gradient, corner_radius, polygon_sides, line_weight) } Tool::ColorSampler diff --git a/crates/ui-egui/src/tools/vector_ui.rs b/crates/ui-egui/src/tools/vector_ui.rs index 72ec518f81..0749b87434 100644 --- a/crates/ui-egui/src/tools/vector_ui.rs +++ b/crates/ui-egui/src/tools/vector_ui.rs @@ -62,6 +62,73 @@ pub use shapes::{create_shape, draw_shape_preview, finish_shape, is_shape_tool, #[cfg(test)] use shapes::{shape_geometry, shape_preview}; +// "Set as default" is a temporary UI choice, not a document property. +#[derive(Clone, Copy, Default)] +struct ArrowDefaultLink { + target: Option<(u64, u64)>, + enabled: bool, +} + +fn arrow_default_link_id() -> egui::Id { + egui::Id::unique("arrow-default-link") +} + +/// Reset the checkbox whenever the selected document, layer or shape changes. +pub(crate) fn track_arrow_default_selection(app: &PhotocraftApp, ctx: &egui::Context) { + let target = app.session.active().and_then(|st| { + let id = st.active_layer?; + let layer = st.doc.layer(id)?; + match &layer.content { + LayerContent::Shape(sh) if matches!(sh.live.as_ref(), Some(photocraft_doc::vector::LiveShape::ArrowLine { .. })) => Some((st.doc.id.0, id.0)), + _ => None, + } + }); + + ctx.data_mut(|data| { + let key = arrow_default_link_id(); + let previous = data.get_temp::(key).unwrap_or_default(); + if previous.target != target { + data.insert_temp(key, ArrowDefaultLink { target, enabled: false }); + } + }); +} + +/// Copy only the selected arrow's live parameters into new-arrow defaults. +/// Existing shapes and geometry are not modified. +fn copy_arrow_to_tool_defaults(app: &mut PhotocraftApp, id: photocraft_doc::LayerId) { + let values = app.session.active().and_then(|st| st.doc.layer(id)).and_then(|layer| match &layer.content { + LayerContent::Shape(sh) => match sh.live.as_ref() { + Some(photocraft_doc::vector::LiveShape::ArrowLine { start, end, width, length, concavity, weight, .. }) => { + { + // Live dimensions may exceed new-arrow input limits after scaling. + // Clamp only the defaults, never the existing shape. + let bounded = |v: f64, min: f64, max: f64| -> f32 { if v.is_finite() { v.clamp(min, max) as f32 } else { min as f32 } }; + Some(( + *start, + *end, + bounded(*width, 1.0, 4096.0), + bounded(*length, 1.0, 4096.0), + bounded(*concavity, 0.0, 100.0), + bounded(*weight, 1.0, 10000.0), + )) + } + } + _ => None, + }, + _ => None, + }); + + if let Some((start, end, width, length, concavity, weight)) = values { + let o = &mut app.ui.tool_options; + o.arrow_start = start; + o.arrow_end = end; + o.arrow_width = width; + o.arrow_length = length; + o.arrow_concavity = concavity; + o.line_weight = weight; + } +} + /// Pen tool path under construction: knots as [anchor, in, out] (document px). #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct PenPath { diff --git a/crates/ui-egui/src/tools/vector_ui/options.rs b/crates/ui-egui/src/tools/vector_ui/options.rs index 571e5dcffc..cd68a6bcb6 100644 --- a/crates/ui-egui/src/tools/vector_ui/options.rs +++ b/crates/ui-egui/src/tools/vector_ui/options.rs @@ -1,6 +1,7 @@ use super::path_ops::active_path_name; use super::pen::path_operations_menu; use super::shapes::is_shape_tool; +use super::{ArrowDefaultLink, arrow_default_link_id, copy_arrow_to_tool_defaults}; use super::{GradientChoices, LayerContent, PaintChange, PhotocraftApp, Tokens, Tool, Value, json, paint_param, swatch}; // --------------------------------------------------------------------------------------------- @@ -114,6 +115,34 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo crate::widgets::dropdown(ui, "shape-mode", tl!("Mode"), &mut mode, &[("shape".to_string(), tl!("Shape"))], 80.0); crate::widgets::vline(ui, 22.0); crate::shape_options::options_bar(app, ui, tool); + // Photoshop-style arrowhead settings at the right of Line options. + if tool == Tool::Line { + crate::widgets::vline(ui, 22.0); + ui.menu_image_button(crate::icons::image("settings", 18.0, t.icon), |ui| { + let o = &mut app.ui.tool_options; + ui.set_min_width(170.0); + ui.label(tl!("Arrowheads")); + ui.separator(); + ui.horizontal(|ui| { + ui.checkbox(&mut o.arrow_start, tl!("Start")); + ui.checkbox(&mut o.arrow_end, tl!("End")); + }); + ui.horizontal(|ui| { + ui.label(tl!("Width")); + ui.add(egui::DragValue::new(&mut o.arrow_width).range(1.0..=4096.0).suffix(" px")); + }); + ui.horizontal(|ui| { + ui.label(tl!("Length")); + ui.add(egui::DragValue::new(&mut o.arrow_length).range(1.0..=4096.0).suffix(" px")); + }); + ui.horizontal(|ui| { + ui.label(tl!("Concavity")); + ui.add(egui::DragValue::new(&mut o.arrow_concavity).range(0.0..=100.0).suffix(" %")); + }); + }) + .response + .on_hover_text(tl!("Arrowhead options")); + } true } @@ -279,7 +308,7 @@ pub fn shape_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: photocra } }); } - photocraft_doc::vector::LiveShape::Line { .. } => {} + photocraft_doc::vector::LiveShape::Line { .. } | photocraft_doc::vector::LiveShape::ArrowLine { .. } => {} } ui.vertical(|ui| match live { photocraft_doc::vector::LiveShape::Rect { radii, .. } => { @@ -323,6 +352,59 @@ pub fn shape_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: photocra }) .inner } + photocraft_doc::vector::LiveShape::ArrowLine { weight, start, end, width, length, concavity, .. } => { + // This is a selected, saved Arrow; unlike the tool's defaults, edits update its geometry. + ui.horizontal(|ui| { + let mut value = *start; + if ui.checkbox(&mut value, tl!("Start")).changed() { + edit = Some(json!({"arrowStart": value, "coalesce": key("arrow-start")})); + } + let mut value = *end; + if ui.checkbox(&mut value, tl!("End")).changed() { + edit = Some(json!({"arrowEnd": value, "coalesce": key("arrow-end")})); + } + }); + ui.horizontal(|ui| { + let mut v = *width as f32; + if num(ui, "Width", &mut v, 1.0..=4096.0, "px") { + edit = Some(json!({"arrowWidth": v, "coalesce": key("arrow-width")})); + } + }); + ui.horizontal(|ui| { + let mut v = *length as f32; + if num(ui, "Length", &mut v, 1.0..=4096.0, "px") { + edit = Some(json!({"arrowLength": v, "coalesce": key("arrow-length")})); + } + }); + ui.horizontal(|ui| { + let mut v = *weight as f32; + if num(ui, "Weight", &mut v, 1.0..=10000.0, "px") { + edit = Some(json!({"weight": v, "coalesce": key("arrow-weight")})); + } + }); + ui.horizontal(|ui| { + let mut v = *concavity as f32; + if num(ui, "Concavity", &mut v, 0.0..=100.0, "%") { + edit = Some(json!({"arrowConcavity": v, "coalesce": key("arrow-concavity")})); + } + }); + + // Explicitly adopt this arrow's values as defaults for new arrows. + let target = app.session.active().map(|st| (st.doc.id.0, id.0)); + let mut linked = ui.ctx().data(|data| { + let state = data.get_temp::(arrow_default_link_id()).unwrap_or_default(); + state.target == target && state.enabled + }); + + if ui.checkbox(&mut linked, tl!("Set as default")).on_hover_text(tl!("Use these settings for new arrows")).changed() { + ui.ctx().data_mut(|data| { + data.insert_temp(arrow_default_link_id(), ArrowDefaultLink { target, enabled: linked }); + }); + if linked { + copy_arrow_to_tool_defaults(app, id); + } + } + } photocraft_doc::vector::LiveShape::Line { weight, .. } => { ui.horizontal(|ui| { let mut w = *weight as f32; @@ -337,11 +419,25 @@ pub fn shape_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: photocra } if let Some(mut p) = edit { p["layer"] = json!(id.0); + match app.run("shape.edit", p) { Ok(_) => { + // Remember recently used colors after a successful edit. if let Some(rgb) = recent { crate::paint_picker::remember(app, rgb); } + + // Only a successful Properties edit updates new-arrow defaults, + // and only while the user explicitly enables the checkbox. + let target = app.session.active().map(|st| (st.doc.id.0, id.0)); + let linked = ui.ctx().data(|data| { + let state = data.get_temp::(arrow_default_link_id()).unwrap_or_default(); + state.enabled && state.target == target + }); + + if linked { + copy_arrow_to_tool_defaults(app, id); + } } Err(e) => { app.ui.status = e; diff --git a/crates/ui-egui/src/tools/vector_ui/overlay.rs b/crates/ui-egui/src/tools/vector_ui/overlay.rs index b72bdd64c5..6bc967e34e 100644 --- a/crates/ui-egui/src/tools/vector_ui/overlay.rs +++ b/crates/ui-egui/src/tools/vector_ui/overlay.rs @@ -170,7 +170,12 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform if let Some(l) = st.and_then(|s| s.active_layer.and_then(|id| s.doc.layer(id))) && let LayerContent::Shape(sh) = &l.content { - draw_path(&sh.path, anchors); + // Hide the stale shape path while its transform preview is active. + let transforming_this_shape = + app.ui.transform.as_ref().is_some_and(|t| t.layer == l.id.0 && !t.selection && t.target.is_none() && t.path.is_none()); + if !transforming_this_shape { + draw_path(&sh.path, anchors); + } } // A targeted vector mask shows its path with any tool (#196). if let Some((_, p)) = targeted_vector_mask(app) { diff --git a/crates/ui-egui/src/tools/vector_ui/shapes.rs b/crates/ui-egui/src/tools/vector_ui/shapes.rs index dc72a120f0..fe96e7b177 100644 --- a/crates/ui-egui/src/tools/vector_ui/shapes.rs +++ b/crates/ui-egui/src/tools/vector_ui/shapes.rs @@ -59,7 +59,10 @@ pub(super) fn shape_geometry(o: &ToolOptions, tool: Tool, start: [f64; 2], end: if dx.hypot(dy) < 1.0 { return None; } - json!({"kind": "line", "from": start, "to": [start[0] + dx, start[1] + dy], "weight": o.line_weight.max(1.0)}) + json!({"kind": "line", "from": start, "to": [start[0] + dx, start[1] + dy], "weight": o.line_weight.max(1.0), + "arrowStart": o.arrow_start, "arrowEnd": o.arrow_end, + "arrowWidth": o.arrow_width, "arrowLength": o.arrow_length, + "arrowConcavity": o.arrow_concavity}) } else { let (mut w, mut h) = (end[0] - start[0], end[1] - start[1]); if mods.shift { diff --git a/crates/vector/src/shapes.rs b/crates/vector/src/shapes.rs index d259d27871..0b7e65a48d 100644 --- a/crates/vector/src/shapes.rs +++ b/crates/vector/src/shapes.rs @@ -215,6 +215,65 @@ pub fn line(a: (f64, f64), b: (f64, f64), weight: f64) -> Path { Path::new(vec![Subpath::polygon(&[(a.0 + nx, a.1 + ny), (b.0 + nx, b.1 + ny), (b.0 - nx, b.1 - ny), (a.0 - nx, a.1 - ny)])]) } +/// A filled vector line with optional arrowheads at its endpoints (#6408). +/// `width` and `length` measure the head in document pixels, independently of the shaft weight. +/// `concavity` is 0..100 percent: 0 has a flat rear edge, 100 a deep V-shaped notch. +/// The shaft stops at the notch, so a concave head remains concave after filling. +// The arguments correspond to the stored LiveShape::ArrowLine parameters. +#[allow(clippy::too_many_arguments)] +pub fn arrow_line(a: (f64, f64), b: (f64, f64), weight: f64, start: bool, end: bool, width: f64, length: f64, concavity: f64) -> Path { + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let distance = dx.hypot(dy); + if !distance.is_finite() || !weight.is_finite() { + return Path::default(); + } + // Without arrowheads, use the ordinary line even for segments shorter than 1 px. + if !start && !end { + return line(a, b, weight); + } + if distance < 1.0 { + return Path::default(); + } + let (ux, uy) = (dx / distance, dy / distance); + let (vx, vy) = (-uy, ux); + let size = (if length.is_finite() { length.max(1.0) } else { 1.0 }).min(distance * if start && end { 0.49 } else { 0.9 }); + let half = (if width.is_finite() { width.max(1.0) } else { 1.0 }) / 2.0; + let requested_notch = concavity.clamp(0.0, 100.0) / 100.0; + // Preserve 0..50%. Beyond that compress the usable range so the head never collapses. + let max_notch = 0.75; + let notch = if requested_notch <= 0.5 { requested_notch } else { 0.5 + (requested_notch - 0.5) * (max_notch - 0.5) / 0.5 }; + // Put the shaft up to the notch, leaving the head's concave edges visible. + let inner = size * (1.0 - notch); + let shaft_a = if start { (a.0 + ux * inner, a.1 + uy * inner) } else { a }; + let shaft_b = if end { (b.0 - ux * inner, b.1 - uy * inner) } else { b }; + let hw = weight.max(0.1) / 2.0; + let mut pieces = vec![Subpath::polygon(&[ + (shaft_a.0 + vx * hw, shaft_a.1 + vy * hw), + (shaft_b.0 + vx * hw, shaft_b.1 + vy * hw), + (shaft_b.0 - vx * hw, shaft_b.1 - vy * hw), + (shaft_a.0 - vx * hw, shaft_a.1 - vy * hw), + ])]; + // The tip, both outer corners of the head and the recessed midpoint. + // Positive `sign` points towards the tail of the head (away from its tip). + let mut head = |tip: (f64, f64), sign: f64| { + let base = (tip.0 + ux * sign * size, tip.1 + uy * sign * size); + let rear_middle = (base.0 - ux * sign * size * notch, base.1 - uy * sign * size * notch); + pieces.push(Subpath::polygon(&[tip, (base.0 + vx * half, base.1 + vy * half), rear_middle, (base.0 - vx * half, base.1 - vy * half)])); + }; + if start { + head(a, 1.0); + } + if end { + head(b, -1.0); + } + // Never return non-finite arrow geometry. + // Large but valid scaled arrowheads remain unchanged. + if pieces.iter().flat_map(|sp| &sp.knots).any(|k| !k.anchor.x.is_finite() || !k.anchor.y.is_finite()) { + return Path::default(); + } + Path::new(pieces) +} + /// The path of a live shape. pub fn live_path(s: &LiveShape) -> Path { match s { @@ -224,6 +283,9 @@ pub fn live_path(s: &LiveShape) -> Path { 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), + LiveShape::ArrowLine { from, to, weight, start, end, width, length, concavity } => { + arrow_line((from[0], from[1]), (to[0], to[1]), *weight, *start, *end, *width, *length, *concavity) + } } } @@ -231,6 +293,33 @@ pub fn live_path(s: &LiveShape) -> Path { mod tests { use super::*; + #[test] + fn rejects_non_finite_arrowhead_coordinates() { + let path = arrow_line((1.5e308, 0.0), (1.5e308, 200.0), 8.0, false, true, f64::MAX, 50.0, 0.0); + assert!(path.subpaths.is_empty()); + } + + #[test] + fn preserves_scaled_head_dimensions_above_ui_limit() { + let path = arrow_line((0.0, 0.0), (20000.0, 0.0), 8.0, false, true, 5200.0, 5200.0, 0.0); + let head = &path.subpaths[1].knots; + assert!((head[1].anchor.x - 14800.0).abs() < 1e-6); + assert!((head[1].anchor.y - 2600.0).abs() < 1e-6); + } + + #[test] + fn live_arrow_can_be_regenerated_after_edit() { + let shape = + LiveShape::ArrowLine { from: [10.0, 10.0], to: [200.0, 10.0], weight: 4.0, start: false, end: true, width: 31.0, length: 31.0, concavity: 50.0 }; + let before = live_path(&shape); + assert_eq!(before.subpaths.len(), 2); + let mut edited = shape; + if let LiveShape::ArrowLine { width, .. } = &mut edited { + *width = 60.0; + } + let after = live_path(&edited); + assert_ne!(before, after); + } /// 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; @@ -317,3 +406,33 @@ mod tests { assert!((top.x - 5.0).abs() < 1e-9 && top.y.abs() < 1e-9); } } + +#[cfg(test)] +mod line_arrow_6408_tests { + use super::*; + + #[test] + fn short_line_without_heads_matches_regular_line() { + let a = (0.0, 0.0); + let b = (0.25, 0.0); + assert_eq!(arrow_line(a, b, 5.0, false, false, 52.0, 50.0, 0.0), line(a, b, 5.0)); + } + + #[test] + fn plain_line_has_single_subpath() { + let p = arrow_line((0.0, 0.0), (100.0, 0.0), 5.0, false, false, 52.0, 50.0, 0.0); + assert_eq!(p.subpaths.len(), 1); + } + + #[test] + fn start_end_arrowheads_add_two_vector_subpaths() { + let p = arrow_line((0.0, 0.0), (100.0, 0.0), 5.0, true, true, 52.0, 50.0, 25.0); + assert_eq!(p.subpaths.len(), 3); + } + + #[test] + fn degenerate_line_returns_empty_path() { + let p = arrow_line((2.0, 2.0), (2.0, 2.0), 5.0, true, true, 52.0, 50.0, 0.0); + assert!(p.subpaths.is_empty()); + } +} diff --git a/docs/shape-strokes.md b/docs/shape-strokes.md index 8271fa1023..54293ab792 100644 --- a/docs/shape-strokes.md +++ b/docs/shape-strokes.md @@ -128,3 +128,16 @@ not offered yet. [cap/join reference](https://helpx.adobe.com/ca/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/change-the-caps-or-joins-of-a-line.html) defines endpoint and corner behavior and the 1–500 miter range. Dash units reuse PhotoCraft's existing documented `ShapeStroke`/`vstk` convention. + +## Editable arrows and older PhotoCraft versions + +Native `.pcraft` documents store arrow geometry in the regular shape path and +the editable arrow parameters separately in the optional `arrow_live` field. + +PhotoCraft versions that do not support editable arrows can open these documents +and display the saved arrow geometry as a regular vector shape. They cannot edit +the arrowhead parameters. + +Re-saving the document in an older version may discard the `arrow_live` +metadata, permanently removing parametric arrow editability from that copy. +Keep the original file when working across versions. \ No newline at end of file