Envelopes: a top object's corners keep their saved anchors when reflected

Saved corners from an .ai file stay on the same anchors when the
envelope is reflected, as they already did when it was moved or
rotated. A reflected outline runs counter-clockwise; the corners were
then mapped as if the outline had been drawn the other way round, no
longer fit it, and were found again from the outline.

On a tie between two corner anchors, or two nearest outline points,
the one met first along the outline in the order it was drawn wins,
for outlines drawn counter-clockwise too.

The docs state the corner rule in full: a handle 0.01 pt long or
shorter makes an anchor a corner whichever way it turns; otherwise its
tangent unit vectors must differ by 0.01 or more in x or y and turn
out of the outline. Content spreads evenly along a segment without
handles.
This commit is contained in:
dotCooCoo
2026-11-09 00:31:07 +07:00
committed by bflatastic
parent 75a328f8b1
commit ed6c0d434f
4 changed files with 79 additions and 43 deletions
+26 -8
View File
@@ -318,14 +318,32 @@ mod tests {
#[test]
fn a_top_object_corner_tie_goes_to_the_first_anchor() {
// Both chamfer anchors lie 30 from the box corner: the first one along the outline wins.
// Both chamfer anchors lie 30 from the box corner: the first one along the outline, in
// the order it was drawn, wins.
let c = |p: (f64, f64)| (p, p, p);
let from_left = outline(&[c((0.0, 30.0)), c((30.0, 0.0)), c((100.0, 0.0)), c((100.0, 100.0)), c((0.0, 100.0))]);
let tl = Coons::from_top_object(&from_left, None).unwrap().corners()[0];
assert!(tl.distance(Point::new(0.0, 30.0)) < 1e-9, "{tl:?}");
let from_top = outline(&[c((30.0, 0.0)), c((100.0, 0.0)), c((100.0, 100.0)), c((0.0, 100.0)), c((0.0, 30.0))]);
let tl = Coons::from_top_object(&from_top, None).unwrap().corners()[0];
assert!(tl.distance(Point::new(30.0, 0.0)) < 1e-9, "{tl:?}");
let tl = |pts: &[Knot]| Coons::from_top_object(&outline(pts), None).unwrap().corners()[0];
let from_left = tl(&[c((0.0, 30.0)), c((30.0, 0.0)), c((100.0, 0.0)), c((100.0, 100.0)), c((0.0, 100.0))]);
assert!(from_left.distance(Point::new(0.0, 30.0)) < 1e-9, "{from_left:?}");
let from_top = tl(&[c((30.0, 0.0)), c((100.0, 0.0)), c((100.0, 100.0)), c((0.0, 100.0)), c((0.0, 30.0))]);
assert!(from_top.distance(Point::new(30.0, 0.0)) < 1e-9, "{from_top:?}");
// Drawn counter-clockwise, the anchor met first in that order wins.
let back = tl(&[c((0.0, 100.0)), c((100.0, 100.0)), c((100.0, 0.0)), c((30.0, 0.0)), c((0.0, 30.0))]);
assert!(back.distance(Point::new(30.0, 0.0)) < 1e-9, "{back:?}");
let back = tl(&[c((0.0, 30.0)), c((0.0, 100.0)), c((100.0, 100.0)), c((100.0, 0.0)), c((30.0, 0.0))]);
assert!(back.distance(Point::new(0.0, 30.0)) < 1e-9, "{back:?}");
}
#[test]
fn a_reflected_top_object_keeps_the_corners_a_file_gives() {
// Reflecting the outline across x = 0 keeps its anchors in the same order, now running
// counter-clockwise: the corners stay on the same anchors, so the content is reflected too.
let mut path = top_split_at_30();
path.transform(Affine::FLIP_X);
let c = Coons::from_top_object(&path, Some([1, 2, 3, 4])).unwrap();
let want = [(-30.0, 0.0), (-100.0, 0.0), (-100.0, 50.0), (0.0, 50.0)].map(Point::from);
assert!(c.corners().iter().zip(want).all(|(p, q)| p.distance(q) < 1e-9), "{:?}", c.corners());
let p = c.eval(0.5, 0.5);
assert!(p.distance(Point::new(-50.0, 17.5)) < 1e-9, "{p:?}");
}
#[test]
@@ -348,7 +366,7 @@ mod tests {
}
#[test]
fn a_top_object_is_not_the_same_after_splitting_a_curve() {
fn a_split_curve_moves_the_top_object_share_to_its_new_anchor() {
// Splitting the top curve at t = 0.3 keeps its shape but changes the polygon shares: the
// new anchor lands at the first piece's share, not at u = 0.3.
let top = CubicBez::new((0.0, 0.0), (40.0, -40.0), (160.0, -40.0), (200.0, 0.0));
+44 -27
View File
@@ -147,30 +147,35 @@ impl Coons {
/// The patch of a top object's outline (the largest subpath of `path`), split into four sides
/// at its corners. Each piece of a side between two anchors or corners takes a share of `u` or
/// `v` in proportion to the length of its control polygon, spread along the piece by its own
/// curve parameter. A straight segment counts as a cubic with its handles a third of the way
/// along, so the content spreads evenly along it. `corners` are the anchors (indices into the
/// outline, in its own order) at the top left, top right, bottom right and bottom left, as a
/// saved file gives them; without them, or when they don't fit the outline, each corner is
/// found from the outline ([`top_object_corner`]). A straight segment closes an open subpath,
/// and segments of no length are left out. A top object's envelope maps onto this patch.
/// curve parameter. A segment without handles counts as a cubic with its handles a third of
/// the way along, so the content spreads evenly along it. `corners` are the anchors (indices
/// into the outline, in its own order) at the top left, top right, bottom right and bottom
/// left, as a saved file gives them. They stay on those anchors when they run the way the
/// outline does or the opposite way, so a reflected outline keeps them; without them, or when
/// they don't fit the outline, each corner is found from the outline ([`top_object_corner`]).
/// A straight segment closes an open subpath, and segments of no length are left out. A top
/// object's envelope maps onto this patch.
pub fn from_top_object(path: &PathData, corners: Option<[u32; 4]>) -> Option<Self> {
let sp = path.subpaths.iter().filter(|s| s.anchors.len() >= 2).max_by(|a, b| a.area().abs().total_cmp(&b.area().abs()))?;
let mut sp = sp.clone();
let count = sp.anchors.len();
let mut corners = corners.map(|c| c.map(|i| i as usize));
// Orient clockwise in y-down (TL → TR → BR → BL).
if sp.signed_area() < 0.0 {
sp.reverse();
corners = corners.map(|c| c.map(|i| count.saturating_sub(1).saturating_sub(i)));
}
// Stored corners count only when they are distinct anchors met in order around the outline.
let corners = corners.filter(|c| {
let fits = |c: &[usize; 4]| {
if !c.iter().all(|i| *i < count) {
return false;
}
let gaps: Vec<usize> = (0..4).map(|k| (c[(k + 1) % 4] + count - c[k]) % count).collect();
gaps.iter().all(|g| *g > 0) && gaps.iter().sum::<usize>() == count
});
};
let given = corners.map(|c| c.map(|i| i as usize));
// Stored corners met in the outline's own order keep it as drawn, even counter-clockwise
// (a reflected outline). Otherwise orient it clockwise in y-down (TL → TR → BR → BL).
let mut corners = given.filter(fits);
let reversed = corners.is_none() && sp.signed_area() < 0.0;
if reversed {
sp.reverse();
corners = given.map(|c| c.map(|i| count.saturating_sub(1).saturating_sub(i))).filter(fits);
}
// Each segment, and whether it is straight: a straight one's parameter runs evenly along it.
let line = |a: Point, b: Point| (CubicBez::new(a, a.lerp(b, 1.0 / 3.0), a.lerp(b, 2.0 / 3.0), b), true);
let mut segs: Vec<(usize, (CubicBez, bool))> = (0..sp.segment_count())
@@ -187,6 +192,12 @@ impl Coons {
segs.retain(|(_, (c, _))| [c.p1, c.p2, c.p3].iter().any(|p| p.distance(c.p0) > 1e-9));
// The kept segment that anchor `k` starts (a segment of no length starts where the next begins).
let starts: Vec<usize> = segs.iter().map(|(i, _)| *i).collect();
// Where each kept segment and the anchor it starts at come in the outline as drawn: a tie
// between corners goes to the one met first there.
let order: Vec<(usize, usize)> = starts
.iter()
.map(|&i| if reversed { (count.saturating_sub(1).saturating_sub(i), (2 * count).saturating_sub(2 + i) % count.max(1)) } else { (i, i) })
.collect();
let segs: Vec<(CubicBez, bool)> = segs.into_iter().map(|(_, s)| s).collect();
let r = segs.iter().map(|(c, _)| c.bounding_box()).reduce(|a, b| a.union(b)).or_else(|| path.bounds())?;
// An outline of fewer than three points (a straight line there and back) maps onto its
@@ -254,7 +265,7 @@ impl Coons {
(place(c[0]), place(c[1]), place(c[2]), place(c[3]))
}
None => {
let corner = |q: Point| top_object_corner(&segs, r, q);
let corner = |q: Point| top_object_corner(&segs, &order, r, q);
(corner(Point::new(r.x0, r.y0)), corner(Point::new(r.x1, r.y0)), corner(Point::new(r.x1, r.y1)), corner(Point::new(r.x0, r.y1)))
}
};
@@ -329,7 +340,7 @@ const MAX_SIDE_POINTS: usize = 1 << 16;
/// of a top object looks for an anchor.
const CORNER_REACH: f64 = 0.475;
/// A handle shorter than this (in points) makes its anchor a corner of a top object.
/// A handle this long (in points) or shorter makes its anchor a corner of a top object.
const SHORT_HANDLE: f64 = 0.01;
/// An anchor whose tangent directions, as unit vectors, differ by less than this in x and in y is
@@ -341,7 +352,7 @@ const SMOOTH_TURN: f64 = 0.01;
const CORNER_END_SNAP: f64 = 0.005;
/// Is the anchor at `a`, reached by handle `h_in` and left by handle `h_out` on an outline that
/// runs clockwise on screen, a corner of a top object? A handle shorter than [`SHORT_HANDLE`]
/// runs clockwise on screen, a corner of a top object? A handle of [`SHORT_HANDLE`] or shorter
/// makes it one. Otherwise its tangent directions must differ by [`SMOOTH_TURN`] or more and turn
/// clockwise, so it points out of the outline.
fn top_object_corner_anchor(h_in: Point, a: Point, h_out: Point) -> bool {
@@ -360,33 +371,39 @@ fn top_object_corner_anchor(h_in: Point, a: Point, h_out: Point) -> bool {
/// Where along `segs` (segment index + parameter, in [0, n)) the corner of a top object nearest
/// corner `q` of its bounding box `r` lies, for an outline that runs clockwise on screen. It is
/// the nearest anchor to `q` that is a corner ([`top_object_corner_anchor`]) and lies within
/// [`CORNER_REACH`] of the box's width and height from `q`; the first such anchor wins a tie.
/// Without one, it is the nearest point of the outline to `q`, moved onto its segment's anchor
/// when within [`CORNER_END_SNAP`] of either end.
fn top_object_corner(segs: &[(CubicBez, bool)], r: Rect, q: Point) -> f64 {
/// [`CORNER_REACH`] of the box's width and height from `q`. Without one, it is the nearest point
/// of the outline to `q`, moved onto its segment's anchor when within [`CORNER_END_SNAP`] of
/// either end. `order` gives where each segment's first anchor and the segment itself come in the
/// outline as drawn, which may run the other way; on a tie the one met first there wins.
fn top_object_corner(segs: &[(CubicBez, bool)], order: &[(usize, usize)], r: Rect, q: Point) -> f64 {
use vectorcraft_geom::kurbo::ParamCurveNearest;
let n = segs.len();
if n == 0 {
return 0.0;
}
let rank = |i: usize| order.get(i).copied().unwrap_or((i, i));
let (reach_x, reach_y) = (r.width() * CORNER_REACH, r.height() * CORNER_REACH);
let mut best: Option<(f64, usize)> = None;
let mut best: Option<(f64, usize, usize)> = None;
for i in 0..n {
let (Some((prev, _)), Some((cur, _))) = (segs.get((i + n - 1) % n), segs.get(i)) else { continue };
let a = cur.p0;
if (a.x - q.x).abs() > reach_x || (a.y - q.y).abs() > reach_y || !top_object_corner_anchor(prev.p2, a, cur.p1) {
continue;
}
let d = a.distance_squared(q);
if best.is_none_or(|(b, _)| d < b) {
best = Some((d, i));
let (d, k) = (a.distance_squared(q), rank(i).0);
if best.is_none_or(|(b, bk, _)| d < b || (d == b && k < bk)) {
best = Some((d, k, i));
}
}
if let Some((_, i)) = best {
if let Some((_, _, i)) = best {
return i as f64;
}
let tol = (r.width() + r.height()).max(1e-6) * 1e-9;
let nearest = segs.iter().enumerate().map(|(i, (c, _))| (c.nearest(q, tol), i)).min_by(|a, b| a.0.distance_sq.total_cmp(&b.0.distance_sq));
let nearest = segs
.iter()
.enumerate()
.map(|(i, (c, _))| (c.nearest(q, tol), i))
.min_by(|a, b| a.0.distance_sq.total_cmp(&b.0.distance_sq).then(rank(a.1).1.cmp(&rank(b.1).1)));
let Some((near, i)) = nearest else { return 0.0 };
if near.t > CORNER_END_SNAP && near.t < 1.0 - CORNER_END_SNAP {
i as f64 + near.t
+8 -7
View File
@@ -6618,13 +6618,14 @@ arcLower, arcUpper, arch, bulge, shellLower, shellUpper, flag, wave, fish, rise,
`cols`: 1–50) and `object.envelope.makeWithTopObject` (the topmost selected path becomes the envelope).
A top object's outline is split into four sides at four corners. Each corner is the anchor nearest a corner of the
outline's bounding box among the anchors within 47.5% of the box's width and height from that box corner that count as
corners: an anchor with a handle shorter than 0.01 pt, or one whose tangent unit vectors differ by 0.01 or more in x or
y and turn out of the outline. The first such anchor along the outline wins a tie. Without one, the corner is the outline's point
nearest the box corner, moved onto the anchor at the end of its segment when within 0.5% of the segment's parameter.
Each piece of a side between anchors and corners takes a share of the content's width or height in proportion to the
length of its control polygon, spread along the piece by its curve parameter, so content spreads evenly along a
straight segment. An envelope opened from an `.ai` file keeps the corners the file saves. These are VectorCraft's own
rules, and existing top-object envelopes redraw by them.
corners: an anchor with a handle 0.01 pt long or shorter, or one whose tangent unit vectors differ by 0.01 or more in
x or y and turn out of the outline. On a tie, the anchor met first along the outline in the order it was drawn wins.
Without such an anchor, the corner is the outline's point nearest the box corner, moved onto the anchor at the end of
its segment when within 0.5% of the segment's parameter. Each piece of a side between anchors and corners takes a
share of the content's width or height in proportion to the length of its control polygon, spread along the piece by
its curve parameter, so content spreads evenly along a segment without handles. An envelope opened from an `.ai` file
keeps the corners the file saves, and they stay on the same anchors when the envelope is moved, rotated or
reflected. These are VectorCraft's own rules, and existing top-object envelopes redraw by them.
`object.envelope.info` reads the selected envelope's settings (`type`, the warp's or mesh's values, `fidelity` and
the options, `editing`), or with no envelope selected the options new envelopes get. With an envelope selected,
`object.envelope.resetWithWarp` and `object.envelope.resetWithMesh` (`rows`, `cols`, `maintainShape`: true keeps
+1 -1
View File
@@ -1472,7 +1472,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Envelopes of type map from the glyphs:** an envelope maps point type, area type, type in groups and layers and type used as a clipping path from the bounds of the glyph outlines, underlines and strikethroughs instead of the type's laid-out box (ascent to descent, or the area's frame), with inline graphics counted by their boxes; rotated type and type in perspective map from their outlines as drawn. Make with Mesh, Make with Top Object and Reset with Mesh without Maintain Shape use those bounds. Type that draws nothing keeps its laid-out box, and so does type inside any other object, such as a blend, a repeat or a symbol instance. This is VectorCraft's own rule; envelopes of type made in VectorCraft and in saved files redraw by it. Tests: `crates/engine/src/tests/envelope.rs`, and `tests/envelope_ink.rs` in `vectorcraft-eps`, `vectorcraft-render` and `vectorcraft-effects`. Scores and hours unchanged.
- **Top-object envelopes share each side by control polygon:** each piece of a top object's four sides takes a share of the content's width or height in proportion to the length of its control polygon, spread along the piece by its curve parameter (evenly along a straight segment), where the sides used to share it out by length. Each corner is the nearest anchor within 47.5% of the bounding box from its box corner that has a handle shorter than 0.01 pt or turns out of the outline, or else the outline's nearest point to that box corner. Envelopes opened from `.ai` files keep the corners the file saves. These are VectorCraft's own rules; existing top-object envelopes redraw by them, and gradient meshes made from a path keep sides measured by length. Tests: `a_top_object_*`, `a_rounded_rectangle_top_object_stays_close_to_the_identity` and `a_rectangle_top_object_is_the_identity` in `live::tests` (`vectorcraft-doc`), `envelope_top_object_shares_a_side_by_control_polygon_length` in `vectorcraft-engine`, `a_top_object_envelope_opens_live_with_its_outline` in `vectorcraft-eps`. Scores and hours unchanged.
- **Top-object envelopes share each side by control polygon:** each piece of a top object's four sides takes a share of the content's width or height in proportion to the length of its control polygon, spread along the piece by its curve parameter (evenly along a segment without handles), where the sides used to share it out by length. Each corner is the nearest anchor within 47.5% of the bounding box from its box corner that has a handle 0.01 pt long or shorter, or whose tangent unit vectors differ by 0.01 or more in x or y and turn out of the outline, or else the outline's nearest point to that box corner. Envelopes opened from `.ai` files keep the corners the file saves, through moves, rotations and reflections. These are VectorCraft's own rules; existing top-object envelopes redraw by them, and gradient meshes made from a path keep sides measured by length. Tests: `a_top_object_*`, `a_reflected_top_object_keeps_the_corners_a_file_gives`, `a_split_curve_moves_the_top_object_share_to_its_new_anchor`, `a_rounded_rectangle_top_object_stays_close_to_the_identity` and `a_rectangle_top_object_is_the_identity` in `live::tests` (`vectorcraft-doc`), `envelope_top_object_shares_a_side_by_control_polygon_length` in `vectorcraft-engine`, `a_top_object_envelope_opens_live_with_its_outline` in `vectorcraft-eps`. Scores and hours unchanged.
- **Texture redraws (#1257):** Craquelure, Mosaic Tiles and Stained Glass compare squared distances when finding their nearest cells; rounding/tie boundaries use the original distance ordering. Brute-force candidate tests and three 1800×1500 viewport comparisons preserve the old pixels. A CMYK rectangle move at 190.07%/DPI 1.5 is reproducible with `cargo test -p vectorcraft-render texture_drag_perf -- --ignored --nocapture`; idle-machine Windows interaction is still unverified.