mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 11:16:53 -04:00
Unlocked rectangle corners now each use their own radius and type (top left, top right, bottom right, bottom left) instead of all following the first one, and straight corners are cut across instead of rounded. Same fix as VectorCraft's copy of the reader (storytold/vectorcraft#1362). Co-authored-by: Claude Opus 5.5 <[email protected]> Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
3 files changed
+108
-38
No files matched your search
@@ -16,7 +16,7 @@ Affinity files.
|
||||
| Artboards (legacy `ABEn`, or current `.af` `phrp`/`aprp` properties, including converted curves), with name, solid background, any transform | artboards (nested ones as groups with a vector mask; rotated/curved boards use their bounding rectangle) |
|
||||
| Layers (`Scop`), groups (`Grup`), pass-through or isolated | groups (pass-through or Normal) |
|
||||
| Curves (`PCrv`): cubic subpaths, closed flags, live corners (`CnrD`) | shape layers (multi-subpath curves fill even-odd) |
|
||||
| Rectangles with corner radii (relative or absolute), ellipses, polygons (smooth too), stars, square stars, pies, triangles, trapezoids, clouds, hearts, cogs, rounded and elliptical callouts, arrows, double stars, tears, crescents, diamonds and circular segments | shape layers |
|
||||
| Rectangles with corner radii (relative or absolute, locked or per corner) and round or straight corners, ellipses, polygons (smooth too), stars, square stars, pies, triangles, trapezoids, clouds, hearts, cogs, rounded and elliptical callouts, arrows, double stars, tears, crescents, diamonds and circular segments | shape layers |
|
||||
| Compound shapes (`Comp`, add and subtract) | one even-odd shape |
|
||||
| A shape or curve with children clips them | group with the outline as vector mask, the shape at its bottom |
|
||||
| Vector masks (`AdCh` curves and shapes) | vector masks |
|
||||
@@ -34,7 +34,7 @@ Affinity files.
|
||||
Everything else is reported in the import warnings, one line per kind with a count, never
|
||||
dropped silently: layer effects, adjustment layers and live filters, brush and pressure strokes,
|
||||
transparency gradients, fill opacity, bitmap fills, master pages, unrecognized
|
||||
parametric shapes (imported as their bounding ellipses), corner types other than round, stars with
|
||||
parametric shapes (imported as their bounding ellipses), corner types other than round or straight, stars with
|
||||
rounded points, several fills or strokes on one object (one of each is used), strokes behind the
|
||||
fill, outlined or scaled text, text fields such as page numbers, frames with columns or a curved
|
||||
outline, grey/Lab/32-bit pixels, CMYK and Lab colour (converted to RGB without the document's
|
||||
|
||||
@@ -51,28 +51,44 @@ pub(crate) fn shape(r: &mut Reader, shape: ObjId, b: [f64; 4], world: Affine) ->
|
||||
let class = r.s.class(shape)?;
|
||||
let sub = match &class.0.to_be_bytes() {
|
||||
b"ShNR" | b"ShRR" => {
|
||||
let rounded = matches!(last(r, shape, b"CTyp"), Some(Value::Array(a)) if !a.is_empty());
|
||||
let radius = match last(r, shape, b"ShCR") {
|
||||
Some(Value::Floats(v)) => v.first().copied().filter(|f| f.is_finite() && *f > 0.0),
|
||||
_ => None,
|
||||
let types = match last(r, shape, b"CTyp") {
|
||||
Some(Value::Array(a)) => a.iter().map(|t| if let Value::Enum { id, .. } = t { Some(*id) } else { None }).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
if let (true, Some(f)) = (rounded, radius) {
|
||||
if let Some(Value::Array(types)) = last(r, shape, b"CTyp")
|
||||
&& types.iter().any(|t| !matches!(t, Value::Enum { id: 0 | 4, .. }))
|
||||
{
|
||||
r.warn("rectangles with straight, concave or other corner types (imported with round corners)");
|
||||
}
|
||||
let (crx, cry) = if r.s.bool(shape, b"AbSz") == Some(true) {
|
||||
// An absolute radius is in document pixels, whatever the node's scale.
|
||||
let [a, b2, c, d, ..] = world.0;
|
||||
let (sx, sy) = ((a * a + b2 * b2).sqrt(), (c * c + d * d).sqrt());
|
||||
if sx > 0.0 && sy > 0.0 { (f / sx, f / sy) } else { (0.0, 0.0) }
|
||||
} else {
|
||||
(f * w.min(h), f * w.min(h))
|
||||
};
|
||||
rounded_rect(b, crx.min(w / 2.0), cry.min(h / 2.0))
|
||||
} else {
|
||||
let radii = match last(r, shape, b"ShCR") {
|
||||
Some(Value::Floats(v)) => v.iter().map(|f| if f.is_finite() && *f > 0.0 { *f } else { 0.0 }).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
// Corners follow the first one (top left) unless they were unlocked; then each has
|
||||
// its own radius and type, clockwise from the top left.
|
||||
let locked = !matches!(last(r, shape, b"Lock"), Some(Value::Bool(false)));
|
||||
let corners: [(f64, Option<u16>); 4] = std::array::from_fn(|i| {
|
||||
let i = if locked { 0 } else { i };
|
||||
(radii.get(i).copied().unwrap_or(0.0), types.get(i).copied().flatten())
|
||||
});
|
||||
if types.is_empty() || corners.iter().all(|(f, _)| *f == 0.0) {
|
||||
polygon([pt(x0, y0), pt(x1, y0), pt(x1, y1), pt(x0, y1)])?
|
||||
} else {
|
||||
if corners.iter().any(|(f, t)| *f > 0.0 && !matches!(t, Some(0 | 1 | 4))) {
|
||||
r.warn("rectangles with concave or other corner types (imported with round corners)");
|
||||
}
|
||||
let absolute = r.s.bool(shape, b"AbSz") == Some(true);
|
||||
let [a, b2, c, d, ..] = world.0;
|
||||
let (sx, sy) = ((a * a + b2 * b2).sqrt(), (c * c + d * d).sqrt());
|
||||
cornered_rect(
|
||||
b,
|
||||
corners.map(|(f, t)| {
|
||||
let (crx, cry) = if !absolute {
|
||||
(f * w.min(h), f * w.min(h))
|
||||
} else if sx > 0.0 && sy > 0.0 {
|
||||
// An absolute radius is in document pixels, whatever the node's scale.
|
||||
(f / sx, f / sy)
|
||||
} else {
|
||||
(0.0, 0.0)
|
||||
};
|
||||
(crx, cry, t == Some(1))
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
b"ShpE" => ellipse(cx, cy, rx, ry),
|
||||
@@ -354,22 +370,31 @@ fn ellipse(cx: f64, cy: f64, rx: f64, ry: f64) -> SubPath {
|
||||
}
|
||||
}
|
||||
|
||||
fn rounded_rect([x0, y0, x1, y1]: [f64; 4], rx: f64, ry: f64) -> SubPath {
|
||||
let (kx, ky) = ((1.0 - KAPPA) * rx, (1.0 - KAPPA) * ry);
|
||||
SubPath {
|
||||
start: pt(x0 + rx, y0),
|
||||
segments: vec![
|
||||
[pt(x1 - rx, y0), pt(x1 - rx, y0), pt(x1 - rx, y0)],
|
||||
[pt(x1 - kx, y0), pt(x1, y0 + ky), pt(x1, y0 + ry)],
|
||||
[pt(x1, y1 - ry), pt(x1, y1 - ry), pt(x1, y1 - ry)],
|
||||
[pt(x1, y1 - ky), pt(x1 - kx, y1), pt(x1 - rx, y1)],
|
||||
[pt(x0 + rx, y1), pt(x0 + rx, y1), pt(x0 + rx, y1)],
|
||||
[pt(x0 + kx, y1), pt(x0, y1 - ky), pt(x0, y1 - ry)],
|
||||
[pt(x0, y0 + ry), pt(x0, y0 + ry), pt(x0, y0 + ry)],
|
||||
[pt(x0, y0 + ky), pt(x0 + kx, y0), pt(x0 + rx, y0)],
|
||||
],
|
||||
closed: true,
|
||||
/// A rectangle whose corners (top left, top right, bottom right, bottom left) are each rounded,
|
||||
/// or cut straight across, by their (horizontal, vertical) radii. Two radii that don't fit along
|
||||
/// one side shrink in proportion until they do.
|
||||
fn cornered_rect([x0, y0, x1, y1]: [f64; 4], corners: [(f64, f64, bool); 4]) -> SubPath {
|
||||
let fit = |r: f64, pair: f64, side: f64| if pair > side { side * (r / pair) } else { r };
|
||||
let [tl, tr, br, bl] = corners;
|
||||
let (w, h) = (x1 - x0, y1 - y0);
|
||||
let rx = [fit(tl.0, tl.0 + tr.0, w), fit(tr.0, tl.0 + tr.0, w), fit(br.0, bl.0 + br.0, w), fit(bl.0, bl.0 + br.0, w)];
|
||||
let ry = [fit(tl.1, tl.1 + bl.1, h), fit(tr.1, tr.1 + br.1, h), fit(br.1, tr.1 + br.1, h), fit(bl.1, tl.1 + bl.1, h)];
|
||||
let (kx, ky) = (rx.map(|r| (1.0 - KAPPA) * r), ry.map(|r| (1.0 - KAPPA) * r));
|
||||
// (where the corner leaves the previous side, its two handles, where it joins the next side)
|
||||
let arcs = [
|
||||
[pt(x1 - rx[1], y0), pt(x1 - kx[1], y0), pt(x1, y0 + ky[1]), pt(x1, y0 + ry[1])],
|
||||
[pt(x1, y1 - ry[2]), pt(x1, y1 - ky[2]), pt(x1 - kx[2], y1), pt(x1 - rx[2], y1)],
|
||||
[pt(x0 + rx[3], y1), pt(x0 + kx[3], y1), pt(x0, y1 - ky[3]), pt(x0, y1 - ry[3])],
|
||||
[pt(x0, y0 + ry[0]), pt(x0, y0 + ky[0]), pt(x0 + kx[0], y0), pt(x0 + rx[0], y0)],
|
||||
];
|
||||
let mut segments = Vec::with_capacity(8);
|
||||
for ([s, c1, c2, e], straight) in arcs.into_iter().zip([tr.2, br.2, bl.2, tl.2]) {
|
||||
segments.push([s, s, s]);
|
||||
if s != e {
|
||||
segments.push(if straight { [e, e, e] } else { [c1, c2, e] });
|
||||
}
|
||||
}
|
||||
SubPath { start: pt(x0 + rx[0], y0), segments, closed: true }
|
||||
}
|
||||
|
||||
/// Elliptical arc from angle `a0` to `a1` (radians, measured y-up as Affinity's pie angles are),
|
||||
|
||||
@@ -352,7 +352,7 @@ mod archive {
|
||||
|
||||
mod shapes {
|
||||
use crate::container::Limits;
|
||||
use crate::model::{Document, Kind, Path};
|
||||
use crate::model::{Document, Kind, Path, Point, SubPath};
|
||||
use crate::stream::Tag;
|
||||
use crate::synth::{self, F, Method, tag};
|
||||
|
||||
@@ -413,4 +413,49 @@ mod shapes {
|
||||
let make = |end| read(vec![shape(100, b"ShPi", vec![(tag(b"AngS"), F::F64(0.0)), (tag(b"AngE"), F::F64(end))])], 100);
|
||||
assert_eq!(path(&make(-std::f64::consts::FRAC_PI_2)), path(&make(3.0 * std::f64::consts::FRAC_PI_2)));
|
||||
}
|
||||
|
||||
/// A 10×20 rectangle (`ShNR`) with the given parameters, read back as its outline.
|
||||
fn rectangle(parameters: Vec<(Tag, F)>) -> (SubPath, Vec<String>) {
|
||||
let node =
|
||||
F::Def(3, vec![tag(b"ShpN")], vec![(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 10.0, 20.0])), (tag(b"Shpe"), F::Def(4, vec![tag(b"ShNR")], parameters))]);
|
||||
let spread = F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(vec![node]))]);
|
||||
let data = synth::stream(&[(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]);
|
||||
let doc = crate::read(&synth::container(&[("doc.dat", &data, Method::Zlib)], None), Limits::default()).unwrap();
|
||||
let Kind::Shape { path, .. } = &doc.spreads[0].nodes[0].kind else { panic!("expected a shape") };
|
||||
(path.subpaths[0].clone(), doc.warnings)
|
||||
}
|
||||
|
||||
/// `CTyp`: an array of corner-type enums (top left, top right, bottom right, bottom left).
|
||||
fn corner_types(ids: [u16; 4]) -> F {
|
||||
let mut raw = vec![0xaa];
|
||||
raw.extend(tag(b"CTyp").0.to_le_bytes());
|
||||
raw.extend(4u32.to_le_bytes());
|
||||
raw.extend(0u16.to_le_bytes());
|
||||
raw.extend(ids.iter().flat_map(|id| id.to_le_bytes()));
|
||||
F::Raw(raw)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unlocked_rectangle_corners_keep_their_own_radius_and_type() {
|
||||
let p = |x: f64, y: f64| Point { x, y };
|
||||
// Relative radii scale by 10. Round 5, round 1, straight 3, none.
|
||||
let corners = |lock: Option<bool>| {
|
||||
let mut fields = vec![(tag(b"ShCR"), F::F64s(vec![0.5, 0.1, 0.3, 0.0])), (tag(b"CTyp"), corner_types([0, 0, 1, 4]))];
|
||||
fields.extend(lock.map(|l| (tag(b"Lock"), F::Bool(l))));
|
||||
rectangle(fields)
|
||||
};
|
||||
|
||||
let (sub, warnings) = corners(Some(false));
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
assert_eq!(sub.start, p(5.0, 0.0));
|
||||
let ends: Vec<Point> = sub.segments.iter().map(|s| s[2]).collect();
|
||||
assert_eq!(ends, [p(9.0, 0.0), p(10.0, 1.0), p(10.0, 17.0), p(7.0, 20.0), p(0.0, 20.0), p(0.0, 5.0), p(5.0, 0.0)]);
|
||||
assert_eq!(sub.segments[3], [p(7.0, 20.0); 3], "the straight corner is a line");
|
||||
assert_ne!(sub.segments[1][0], p(9.0, 0.0), "the round corner is a curve");
|
||||
|
||||
// Locked (the default): every corner follows the top left one.
|
||||
let (sub, _) = corners(None);
|
||||
assert_eq!(sub.segments.len(), 8);
|
||||
assert_eq!(sub.segments[3][2], p(5.0, 20.0));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user