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
This commit is contained in:
Gabriel Heuser
2026-11-02 01:46:05 +02:00
committed by GitHub
parent 2df9f80770
commit 24be8ea6e2
20 changed files with 979 additions and 27 deletions
+11
View File
@@ -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.
+2 -2
View File
@@ -98,7 +98,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<CommandSpec> {
"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
+43 -5
View File
@@ -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<f64> {
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<bool> {
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}`"))),
+268 -2
View File
@@ -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<Value> {
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<Value> {
}
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);
}
}
+144
View File
@@ -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"]);
}
+12 -3
View File
@@ -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,
+4
View File
@@ -300,6 +300,10 @@ pub enum ContentM {
stroke: Option<ShapeStroke>,
#[serde(default)]
live: Option<LiveShape>,
/// 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<LiveShape>,
/// The path's density as the fill's mask; older files have none (100 %).
#[serde(default = "full_density")]
density: f32,
+54
View File
@@ -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<LegacyLive>,
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);
}
+36 -5
View File
@@ -407,11 +407,25 @@ pub fn live_from_vogk(data: &[u8]) -> Option<LiveShape> {
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<Vec<u8>> {
.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));
+69 -5
View File
@@ -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<f32>, h: Option<f32>, linked: bool, tol: f32) -> Option<Value> {
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<f32>, h: Option<f32>,
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]
+1
View File
@@ -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")
+11
View File
@@ -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(),
+1 -1
View File
@@ -178,7 +178,7 @@ fn target(layer: u64, live: &LiveShape) -> Option<Target> {
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,
}
}
+17 -1
View File
@@ -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
+67
View File
@@ -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::<ArrowDefaultLink>(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 {
+97 -1
View File
@@ -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::<ArrowDefaultLink>(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::<ArrowDefaultLink>(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;
@@ -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) {
+4 -1
View File
@@ -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 {
+119
View File
@@ -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());
}
}
+13
View File
@@ -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.