mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-08 21:10:40 +00:00
Envelopes: a top object's sides share the content by control polygon length
A top-object envelope splits its outline into four sides at four corners and maps the content onto the Coons patch of those sides. The sides shared out the content by length, and the corners were the anchors or curve points nearest the bounding box corners. Each piece of a side between anchors and corners now 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. A straight segment spreads it evenly by length. A rounded rectangle stays close to the identity, and the content follows the curves of a side whose segments differ in size. Each corner is now the nearest anchor to its bounding box corner among the sharp anchors that point out of the outline and lie within 47.5% of the box's width and height from that corner. A handle shorter than 0.01 pt, or tangent unit vectors that differ by 0.01 or more in x or y, makes an anchor sharp, and the first one along the outline wins a tie. Without such an anchor, the corner is the outline's nearest point to the box corner, moved onto the end anchor of its segment within 0.5% of the segment's parameter. Envelopes opened from .ai files keep the corners the file saves. These are VectorCraft's own rules. Existing top-object envelopes redraw by them. Gradient meshes made from a path keep their sides measured by length. - crates/doc/src/live/mesh.rs: Coons::from_top_object and the corner search, used for top-object envelopes. - EnvelopeKind::TopObject gains an optional `corners` field (anchor indices); the .ai importer fills it from the mesh's corners. - Params text of object.envelope.makeWithTopObject, docs/mcp.md (Envelopes) and a progress line in docs/target-app-parity.md.
This commit is contained in:
@@ -164,7 +164,7 @@ impl Document {
|
||||
}
|
||||
}
|
||||
NodeKind::Envelope { kind, .. } => {
|
||||
if let EnvelopeKind::TopObject { path } = kind.as_mut() {
|
||||
if let EnvelopeKind::TopObject { path, .. } = kind.as_mut() {
|
||||
round_path(path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +174,276 @@ mod tests {
|
||||
assert!((c.x - 105.0).abs() < 1e-6 && (c.y + 10.0).abs() < 1e-6, "{c:?}");
|
||||
}
|
||||
|
||||
/// A 100 × 50 outline whose top side runs (0,0) → (30,0) → (100,0) as two straight segments.
|
||||
fn top_split_at_30() -> PathData {
|
||||
let a = |x: f64, y: f64| Anchor::corner(Point::new(x, y));
|
||||
PathData::single(SubPath::new(vec![a(0.0, 0.0), a(30.0, 0.0), a(100.0, 0.0), a(100.0, 50.0), a(0.0, 50.0)], true))
|
||||
}
|
||||
|
||||
/// The length of `c`'s control polygon.
|
||||
fn polygon(c: CubicBez) -> f64 {
|
||||
c.p0.distance(c.p1) + c.p1.distance(c.p2) + c.p2.distance(c.p3)
|
||||
}
|
||||
|
||||
/// An anchor and its incoming and outgoing handles.
|
||||
type Knot = ((f64, f64), (f64, f64), (f64, f64));
|
||||
|
||||
/// A closed path through `pts`.
|
||||
fn outline(pts: &[Knot]) -> PathData {
|
||||
let a = |&(p, i, o): &Knot| Anchor { p: p.into(), h_in: i.into(), h_out: o.into(), ..Anchor::corner(p.into()) };
|
||||
PathData::single(SubPath::new(pts.iter().map(a).collect(), true))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_top_object_side_of_straight_segments_spreads_by_length() {
|
||||
// The top side runs (0,0) → (30,0) → (100,0): each straight piece takes its length's share.
|
||||
let kind = EnvelopeKind::TopObject { path: top_split_at_30(), corners: None };
|
||||
let map = envelope_mapper(&kind, Rect::new(0.0, 0.0, 100.0, 50.0));
|
||||
for (q, want) in [((50.0, 0.0), (50.0, 0.0)), ((15.0, 0.0), (15.0, 0.0)), ((80.0, 0.0), (80.0, 0.0)), ((50.0, 25.0), (50.0, 25.0))] {
|
||||
let p = map(Point::new(q.0, q.1));
|
||||
assert!(p.distance(Point::new(want.0, want.1)) < 1e-6, "{q:?} → {p:?}, want {want:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_top_object_shares_a_side_by_control_polygon_length() {
|
||||
// Two curves of unequal polygons make the top side; the anchor between them lands at the
|
||||
// first one's share, and inside each curve the content follows its parameter.
|
||||
let first = CubicBez::new((0.0, 0.0), (10.0, -20.0), (30.0, -20.0), (40.0, 0.0));
|
||||
let second = CubicBez::new((40.0, 0.0), (60.0, 10.0), (90.0, 10.0), (100.0, 0.0));
|
||||
let path = outline(&[
|
||||
((0.0, 0.0), (0.0, 0.0), (10.0, -20.0)),
|
||||
((40.0, 0.0), (30.0, -20.0), (60.0, 10.0)),
|
||||
((100.0, 0.0), (90.0, 10.0), (100.0, 0.0)),
|
||||
((100.0, 80.0), (100.0, 80.0), (100.0, 80.0)),
|
||||
((0.0, 80.0), (0.0, 80.0), (0.0, 80.0)),
|
||||
]);
|
||||
let c = Coons::from_top_object(&path, None).unwrap();
|
||||
let share = polygon(first) / (polygon(first) + polygon(second));
|
||||
assert!(c.eval(share, 0.0).distance(Point::new(40.0, 0.0)) < 1e-9, "{:?}", c.eval(share, 0.0));
|
||||
for f in [0.25, 0.5, 0.75] {
|
||||
let p = c.eval(share * f, 0.0);
|
||||
assert!(p.distance(first.eval(f)) < 1e-9, "first curve at {f}: {p:?}");
|
||||
let p = c.eval(share + (1.0 - share) * f, 0.0);
|
||||
assert!(p.distance(second.eval(f)) < 1e-9, "second curve at {f}: {p:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rounded_rectangle_top_object_stays_close_to_the_identity() {
|
||||
// The corners fall at the middle of each corner arc. Each half arc takes the share of its
|
||||
// control polygon, so the straight edges stay close to where the content was.
|
||||
let r = Rect::new(0.0, 0.0, 200.0, 100.0);
|
||||
let path = shapes::rounded_rectangle(r, 10.0);
|
||||
let c = Coons::from_top_object(&path, None).unwrap();
|
||||
let [tl, tr, br, bl] = c.corners();
|
||||
let k = 10.0 * (1.0 - std::f64::consts::FRAC_1_SQRT_2);
|
||||
for (p, want) in [(tl, (k, k)), (tr, (200.0 - k, k)), (br, (200.0 - k, 100.0 - k)), (bl, (k, 100.0 - k))] {
|
||||
assert!(p.distance(Point::from(want)) < 0.01, "corner {p:?}, want {want:?}");
|
||||
}
|
||||
for (u, v) in [(0.25, 0.0), (0.5, 0.0), (0.8, 1.0), (0.0, 0.5), (0.5, 0.5), (1.0, 0.3)] {
|
||||
let p = c.eval(u, v);
|
||||
assert!(p.distance(Point::new(200.0 * u, 100.0 * v)) < 3.0, "({u}, {v}) → {p:?}");
|
||||
}
|
||||
// The top edge starts after the first half arc's share.
|
||||
let sp = &path.subpaths[0];
|
||||
let edge = Point::new(10.0, 0.0);
|
||||
let arc = (0..sp.segment_count())
|
||||
.filter(|i| !sp.segment_is_line(*i))
|
||||
.map(|i| sp.segment(i))
|
||||
.find(|s| s.p0.distance(edge) < 1e-9 || s.p3.distance(edge) < 1e-9)
|
||||
.unwrap();
|
||||
let half = polygon(if arc.p3.distance(edge) < 1e-9 { arc.subsegment(0.5..1.0) } else { arc.subsegment(0.0..0.5) });
|
||||
let p = c.eval(half / (2.0 * half + 180.0), 0.0);
|
||||
assert!(p.distance(Point::new(10.0, 0.0)) < 1e-6, "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_top_object_corner_inside_a_bulging_curve_is_the_nearest_outline_point() {
|
||||
// The top left anchor's handle bulges out past the corner, so the anchor turns inward
|
||||
// there and the corner falls inside the curve.
|
||||
let top = CubicBez::new((0.0, 0.0), (-50.0, -50.0), (150.0, 0.0), (200.0, 0.0));
|
||||
let path = outline(&[
|
||||
((0.0, 0.0), (0.0, 0.0), (-50.0, -50.0)),
|
||||
((200.0, 0.0), (150.0, 0.0), (200.0, 0.0)),
|
||||
((200.0, 100.0), (200.0, 100.0), (200.0, 100.0)),
|
||||
((0.0, 100.0), (0.0, 100.0), (0.0, 100.0)),
|
||||
]);
|
||||
use vectorcraft_geom::kurbo::ParamCurveNearest;
|
||||
let b = path.subpaths[0].segment(3).bounding_box().union(top.bounding_box());
|
||||
let want = top.eval(top.nearest(Point::new(b.x0, b.y0), 1e-12).t);
|
||||
let tl = Coons::from_top_object(&path, None).unwrap().corners()[0];
|
||||
assert!(tl.distance(want) < 1e-6, "{tl:?}, want {want:?}");
|
||||
assert!(tl.distance(Point::ZERO) > 1.0, "{tl:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_top_object_corner_takes_an_anchor_only_near_its_box_corner() {
|
||||
// A chamfered top left corner of a 200 × 100 outline. With both chamfer anchors within
|
||||
// 47.5% of the box from its corner, the nearer anchor is the corner.
|
||||
let chamfer = |a: (f64, f64), b: (f64, f64)| {
|
||||
let c = |p: (f64, f64)| (p, p, p);
|
||||
outline(&[c(a), c(b), c((200.0, 0.0)), c((200.0, 100.0)), c((0.0, 100.0))])
|
||||
};
|
||||
let tl = Coons::from_top_object(&chamfer((0.0, 46.0), (94.0, 0.0)), None).unwrap().corners()[0];
|
||||
assert!(tl.distance(Point::new(0.0, 46.0)) < 1e-9, "{tl:?}");
|
||||
// Farther out, neither anchor counts: the corner is the chamfer's point nearest the box corner.
|
||||
let tl = Coons::from_top_object(&chamfer((0.0, 50.0), (100.0, 0.0)), None).unwrap().corners()[0];
|
||||
assert!(tl.distance(Point::new(20.0, 40.0)) < 1e-6, "{tl:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_top_object_corner_skips_smooth_and_inward_anchors() {
|
||||
// A top left anchor whose outgoing handle turns from its incoming one by `deg` degrees
|
||||
// (positive turns out of the outline). The top side bulges up past it.
|
||||
let shape = |deg: f64| {
|
||||
let a = deg.to_radians();
|
||||
let (hx, hy) = (50.0 * a.cos() + 40.0 * a.sin(), 40.0 * a.cos() - 50.0 * a.sin());
|
||||
outline(&[
|
||||
((50.0, -300.0), (25.0, -280.0), (50.0 + hx, -300.0 - hy)),
|
||||
((250.0, -300.0), (200.0, -340.0), (250.0, -300.0)),
|
||||
((250.0, -200.0), (250.0, -200.0), (250.0, -200.0)),
|
||||
((50.0, -200.0), (50.0, -200.0), (50.0, -230.0)),
|
||||
])
|
||||
};
|
||||
let corner = |deg: f64| Coons::from_top_object(&shape(deg), None).unwrap().corners()[0];
|
||||
let anchor = Point::new(50.0, -300.0);
|
||||
for deg in [0.0, 0.4, 0.6, -1.5] {
|
||||
assert!(corner(deg).distance(anchor) > 1.0, "{deg}°: {:?}", corner(deg));
|
||||
}
|
||||
for deg in [0.9, 1.5, 10.0] {
|
||||
assert!(corner(deg).distance(anchor) < 1e-9, "{deg}°: {:?}", corner(deg));
|
||||
}
|
||||
}
|
||||
|
||||
#[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.
|
||||
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:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_top_object_keeps_the_corners_a_file_gives() {
|
||||
let same = |a: [Point; 4], b: [Point; 4]| a.iter().zip(b).all(|(p, q)| p.distance(q) < 1e-9);
|
||||
let want = [(30.0, 0.0), (100.0, 0.0), (100.0, 50.0), (0.0, 50.0)].map(Point::from);
|
||||
let c = Coons::from_top_object(&top_split_at_30(), Some([1, 2, 3, 4])).unwrap();
|
||||
assert!(same(c.corners(), want), "{:?}", c.corners());
|
||||
// Drawn the other way round, with the corners counted along that order.
|
||||
let mut back = top_split_at_30();
|
||||
back.subpaths[0].reverse();
|
||||
let c = Coons::from_top_object(&back, Some([3, 2, 1, 0])).unwrap();
|
||||
assert!(same(c.corners(), want), "{:?}", c.corners());
|
||||
// Corners that repeat, run out of order or go past the outline are found from it instead.
|
||||
let found = [(0.0, 0.0), (100.0, 0.0), (100.0, 50.0), (0.0, 50.0)].map(Point::from);
|
||||
for bad in [[0, 0, 3, 4], [2, 1, 3, 4], [1, 2, 3, 9]] {
|
||||
let c = Coons::from_top_object(&top_split_at_30(), Some(bad)).unwrap();
|
||||
assert!(same(c.corners(), found), "{bad:?}: {:?}", c.corners());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_top_object_is_not_the_same_after_splitting_a_curve() {
|
||||
// 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));
|
||||
let (a, b) = (top.subsegment(0.0..0.3), top.subsegment(0.3..1.0));
|
||||
let path = outline(&[
|
||||
((0.0, 0.0), (0.0, 0.0), a.p1.into()),
|
||||
(a.p3.into(), a.p2.into(), b.p1.into()),
|
||||
((200.0, 0.0), b.p2.into(), (200.0, 0.0)),
|
||||
((200.0, 100.0), (200.0, 100.0), (200.0, 100.0)),
|
||||
((0.0, 100.0), (0.0, 100.0), (0.0, 100.0)),
|
||||
]);
|
||||
let c = Coons::from_top_object(&path, None).unwrap();
|
||||
let share = polygon(a) / (polygon(a) + polygon(b));
|
||||
assert!(c.eval(share, 0.0).distance(a.p3) < 1e-9, "{:?}", c.eval(share, 0.0));
|
||||
assert!((share - 0.3).abs() > 0.01, "{share}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rectangle_top_object_is_the_identity() {
|
||||
let r = Rect::new(10.0, 20.0, 110.0, 70.0);
|
||||
let c = Coons::from_top_object(&shapes::rectangle(r), None).unwrap();
|
||||
for (u, v) in [(0.125, 0.3), (0.9, 0.05), (0.37, 0.81)] {
|
||||
let p = c.eval(u, v);
|
||||
assert!(p.distance(Point::new(10.0 + 100.0 * u, 20.0 + 50.0 * v)) < 1e-9, "({u}, {v}) → {p:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_straight_line_top_object_maps_onto_its_bounding_box() {
|
||||
let line = PathData::single(SubPath::new(vec![Anchor::corner(Point::new(0.0, 0.0)), Anchor::corner(Point::new(100.0, 40.0))], false));
|
||||
let p = Coons::from_top_object(&line, None).unwrap().eval(0.25, 0.75);
|
||||
assert!(p.distance(Point::new(25.0, 30.0)) < 1e-9, "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_top_object_spreads_a_segment_by_its_parameter() {
|
||||
// The top side is one straight segment whose handles sit at 60 and 80: its parameter
|
||||
// reaches the middle at x = 63.75, not at 45.
|
||||
let mut top = vec![Anchor::corner(Point::new(0.0, 0.0)), Anchor::corner(Point::new(90.0, 0.0))];
|
||||
top[0].h_out = Point::new(60.0, 0.0);
|
||||
top[1].h_in = Point::new(80.0, 0.0);
|
||||
top.extend([Anchor::corner(Point::new(90.0, 40.0)), Anchor::corner(Point::new(0.0, 40.0))]);
|
||||
let kind = EnvelopeKind::TopObject { path: PathData::single(SubPath::new(top, true)), corners: None };
|
||||
let map = envelope_mapper(&kind, Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
let p = map(Point::new(5.0, 0.0));
|
||||
assert!(p.distance(Point::new(63.75, 0.0)) < 1e-6, "{p:?}");
|
||||
let p = map(Point::new(5.0, 10.0));
|
||||
assert!(p.distance(Point::new(45.0, 40.0)) < 1e-6, "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_top_object_follows_a_curved_side_by_its_parameter() {
|
||||
// The top side bends down from (0,0) to (100,0) through handles at (20,30) and (90,30):
|
||||
// halfway along its parameter it passes (53.75, 22.5).
|
||||
let mut top = vec![Anchor::corner(Point::new(0.0, 0.0)), Anchor::corner(Point::new(100.0, 0.0))];
|
||||
top[0].h_out = Point::new(20.0, 30.0);
|
||||
top[1].h_in = Point::new(90.0, 30.0);
|
||||
top.extend([Anchor::corner(Point::new(100.0, 80.0)), Anchor::corner(Point::new(0.0, 80.0))]);
|
||||
let path = PathData::single(SubPath::new(top, true));
|
||||
let c = Coons::from_top_object(&path, None).unwrap();
|
||||
for (u, t) in [(0.5, 0.5), (0.25, 0.25), (0.8125, 0.8125)] {
|
||||
let want = CubicBez::new((0.0, 0.0), (20.0, 30.0), (90.0, 30.0), (100.0, 0.0)).eval(t);
|
||||
let p = c.eval(u, 0.0);
|
||||
assert!(p.distance(want) < 1e-6, "u {u}: {p:?} vs {want:?}");
|
||||
}
|
||||
// Drawn the other way round, the outline gives the same patch.
|
||||
let mut back = path.clone();
|
||||
back.subpaths[0].reverse();
|
||||
let p = Coons::from_top_object(&back, None).unwrap().eval(0.5, 0.0);
|
||||
assert!(p.distance(Point::new(53.75, 22.5)) < 1e-6, "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_top_object_side_of_many_segments_keeps_each_corner() {
|
||||
// The top side zigzags through `n` straight segments, between y = 0 and y = 2.
|
||||
for n in [3, 100] {
|
||||
let w = f64::from(n);
|
||||
let zig = |i: i32| Point::new(f64::from(i) * 100.0 / w, if i % 2 == 1 { 2.0 } else { 0.0 });
|
||||
let mut pts: Vec<Anchor> = (0..=n).map(|i| Anchor::corner(zig(i))).collect();
|
||||
pts.extend([Anchor::corner(Point::new(100.0, 50.0)), Anchor::corner(Point::new(0.0, 50.0))]);
|
||||
let c = Coons::from_top_object(&PathData::single(SubPath::new(pts, true)), None).unwrap();
|
||||
for i in (1..n).filter(|i| n < 10 || [1, 37, 38, 99].contains(i)) {
|
||||
let p = c.eval(f64::from(i) / w, 0.0);
|
||||
assert!(p.distance(zig(i)) < 1e-9, "{n} segments, anchor {i}: {p:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gradient_mesh_outline_keeps_its_sides_measured_by_length() {
|
||||
let c = Coons::from_path(&top_split_at_30()).unwrap();
|
||||
let p = c.eval(0.5, 0.0);
|
||||
assert!(p.distance(Point::new(50.0, 0.0)) < 1e-6, "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_identity() {
|
||||
let r = Rect::new(0.0, 0.0, 100.0, 100.0);
|
||||
|
||||
@@ -32,7 +32,14 @@ pub enum EnvelopeKind {
|
||||
handles: Vec<[Vec2; 4]>,
|
||||
},
|
||||
/// The content's bounding box maps onto the outline of this path.
|
||||
TopObject { path: PathData },
|
||||
TopObject {
|
||||
path: PathData,
|
||||
/// The anchors (indices into the outline, the path's largest subpath, in its own order)
|
||||
/// at the top left, top right, bottom right and bottom left corners, as a saved file gives
|
||||
/// them. `None`: the corners are found from the outline.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
corners: Option<[u32; 4]>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Envelope Options → Preserve Shape Using: how a distorted raster keeps the envelope's shape.
|
||||
@@ -518,7 +525,7 @@ pub fn envelope_overlay(n: &Node) -> Option<(PathData, Option<GradientMesh>)> {
|
||||
Some((grid.lines(), Some(grid)))
|
||||
}
|
||||
EnvelopeKind::Warp { .. } => Some((EnvelopeMap::of(n)?.surface_mesh(4, 4, Color::BLACK).lines(), None)),
|
||||
EnvelopeKind::TopObject { path } => Some((path.clone(), None)),
|
||||
EnvelopeKind::TopObject { path, .. } => Some((path.clone(), None)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -642,7 +649,7 @@ pub fn envelope_bounds(content: &[Arc<Node>], kind: &EnvelopeKind, frame: Affine
|
||||
let first = *points.first()?;
|
||||
Some(points.iter().fold(Rect::from_points(first, first), |r, p| r.union_pt(*p)))
|
||||
}
|
||||
EnvelopeKind::TopObject { path } => path.bounds(),
|
||||
EnvelopeKind::TopObject { path, .. } => path.bounds(),
|
||||
EnvelopeKind::Warp { .. } => EnvelopeMap::new(content, kind, frame, options)?.bounds(),
|
||||
}
|
||||
}
|
||||
|
||||
+242
-7
@@ -73,13 +73,16 @@ const SIDE: usize = 65;
|
||||
|
||||
/// Four boundary curves (top TL→TR, right TR→BR, bottom BL→BR, left TL→BL), each resampled to
|
||||
/// [`SIDE`] points, from the outline of a closed path split at the points nearest its bounding
|
||||
/// box corners.
|
||||
/// box corners. [`Coons::from_top_object`] samples its sides at the places `knots` gives instead.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Coons {
|
||||
top: Vec<Point>,
|
||||
right: Vec<Point>,
|
||||
bottom: Vec<Point>,
|
||||
left: Vec<Point>,
|
||||
/// Where along its side (0 to 1, rising) each point of the top, right, bottom and left side
|
||||
/// lies; `None` when the points are evenly spaced.
|
||||
knots: Option<Box<[Vec<f64>; 4]>>,
|
||||
}
|
||||
|
||||
impl Coons {
|
||||
@@ -87,9 +90,11 @@ impl Coons {
|
||||
pub fn rect(r: Rect) -> Self {
|
||||
let line = |a: Point, b: Point| (0..SIDE).map(|i| a.lerp(b, i as f64 / (SIDE - 1) as f64)).collect::<Vec<_>>();
|
||||
let (tl, tr, br, bl) = (Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1));
|
||||
Self { top: line(tl, tr), right: line(tr, br), bottom: line(bl, br), left: line(tl, bl) }
|
||||
Self { top: line(tl, tr), right: line(tr, br), bottom: line(bl, br), left: line(tl, bl), knots: None }
|
||||
}
|
||||
|
||||
/// The patch of `path`'s outline, each side measured by its length: equal steps in `u` or `v`
|
||||
/// cover equal lengths of the side.
|
||||
pub fn from_path(path: &PathData) -> Option<Self> {
|
||||
// The largest subpath is the outline.
|
||||
let sp = path.subpaths.iter().filter(|s| s.anchors.len() >= 2).max_by(|a, b| a.area().abs().total_cmp(&b.area().abs()))?;
|
||||
@@ -136,7 +141,134 @@ impl Coons {
|
||||
bottom.reverse();
|
||||
let mut left = resample(&arc(bl, tl), SIDE);
|
||||
left.reverse();
|
||||
Some(Self { top, right, bottom, left })
|
||||
Some(Self { top, right, bottom, left, knots: None })
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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| {
|
||||
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
|
||||
});
|
||||
// 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())
|
||||
.map(|i| {
|
||||
let c = sp.segment(i);
|
||||
(i, if sp.segment_is_line(i) { line(c.p0, c.p3) } else { (c, false) })
|
||||
})
|
||||
.collect();
|
||||
if !sp.closed
|
||||
&& let (Some(a), Some(b)) = (sp.anchors.last(), sp.anchors.first())
|
||||
{
|
||||
segs.push((count.saturating_sub(1), line(a.p, b.p)));
|
||||
}
|
||||
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();
|
||||
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
|
||||
// bounding box, as in `from_path`.
|
||||
if segs.len() < 2 || (segs.len() == 2 && segs.iter().all(|(_, straight)| *straight)) {
|
||||
return Some(Self::rect(r));
|
||||
}
|
||||
let n = segs.len() as f64;
|
||||
let at = |s: f64| {
|
||||
let s = s.rem_euclid(n);
|
||||
let i = (s.floor() as usize).min(segs.len() - 1);
|
||||
segs.get(i).map_or(Point::ZERO, |(c, _)| c.eval((s - i as f64).clamp(0.0, 1.0)))
|
||||
};
|
||||
// The side from `a` to `b`: its points, and where along it (0 to 1) each lies. It runs
|
||||
// through pieces of segments, split at `a` and `b`. Each piece takes its share of the side
|
||||
// by the length of its control polygon and holds points at even steps of its parameter
|
||||
// (at least 16, and at least `SIDE` points on the side), its ends included. A side of
|
||||
// many pieces takes fewer points in each, so it holds at most `MAX_SIDE_POINTS` points,
|
||||
// or one for each piece when it has more pieces than that.
|
||||
let side = |a: f64, b: f64| {
|
||||
let span = (b - a).rem_euclid(n);
|
||||
if !(span.is_finite() && span > 0.0) {
|
||||
return (vec![at(a), at(a)], vec![0.0, 1.0]);
|
||||
}
|
||||
let mut pieces: Vec<CubicBez> = vec![];
|
||||
let (mut s, end) = (a, a + span);
|
||||
while s < end - 1e-12 && pieces.len() <= segs.len() {
|
||||
let i = s.floor();
|
||||
let next = (i + 1.0).min(end);
|
||||
if let Some((c, _)) = segs.get((i as usize) % segs.len()) {
|
||||
pieces.push(c.subsegment((s - i).clamp(0.0, 1.0)..(next - i).clamp(0.0, 1.0)));
|
||||
}
|
||||
s = next;
|
||||
}
|
||||
let polygon = |c: &CubicBez| c.p0.distance(c.p1) + c.p1.distance(c.p2) + c.p2.distance(c.p3);
|
||||
let mut weights: Vec<f64> = pieces.iter().map(polygon).collect();
|
||||
let mut total: f64 = weights.iter().sum();
|
||||
if !(total.is_finite() && total > 0.0) {
|
||||
weights.iter_mut().for_each(|w| *w = 1.0);
|
||||
total = weights.len().max(1) as f64;
|
||||
}
|
||||
let count = pieces.len().max(1);
|
||||
let per = 16usize.max((SIDE - 1).div_ceil(count));
|
||||
let per = if count * per < MAX_SIDE_POINTS { per } else { (MAX_SIDE_POINTS / count).max(1) };
|
||||
let mut pts = vec![pieces.first().map_or_else(|| at(a), |c| c.p0)];
|
||||
let mut knots = vec![0.0];
|
||||
let mut before = 0.0;
|
||||
for (c, w) in pieces.iter().zip(&weights) {
|
||||
for k in 1..=per {
|
||||
let f = k as f64 / per as f64;
|
||||
pts.push(c.eval(f));
|
||||
knots.push((before + w * f) / total);
|
||||
}
|
||||
before += w;
|
||||
}
|
||||
if let Some(last) = knots.last_mut() {
|
||||
*last = 1.0;
|
||||
}
|
||||
(pts, knots)
|
||||
};
|
||||
let (tl, tr, br, bl) = match corners {
|
||||
// Anchor `k` sits at the start of the first kept segment from it on.
|
||||
Some(c) => {
|
||||
let place = |k: usize| starts.iter().position(|i| *i >= k).unwrap_or(0) as f64;
|
||||
(place(c[0]), place(c[1]), place(c[2]), place(c[3]))
|
||||
}
|
||||
None => {
|
||||
let corner = |q: Point| top_object_corner(&segs, 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)))
|
||||
}
|
||||
};
|
||||
let flip = |(mut p, mut u): (Vec<Point>, Vec<f64>)| {
|
||||
p.reverse();
|
||||
u.reverse();
|
||||
u.iter_mut().for_each(|x| *x = 1.0 - *x);
|
||||
(p, u)
|
||||
};
|
||||
let (top, top_u) = side(tl, tr);
|
||||
let (right, right_u) = side(tr, br);
|
||||
let (bottom, bottom_u) = flip(side(br, bl));
|
||||
let (left, left_u) = flip(side(bl, tl));
|
||||
Some(Self { top, right, bottom, left, knots: Some(Box::new([top_u, right_u, bottom_u, left_u])) })
|
||||
}
|
||||
|
||||
/// The point at `u` along a side; past its ends the end segments go on straight (art an
|
||||
@@ -147,10 +279,36 @@ impl Coons {
|
||||
side[i].lerp(side[i + 1], s - i as f64)
|
||||
}
|
||||
|
||||
/// The point at `u` along a side whose points lie at `knots` (rising, 0 to 1); past its ends
|
||||
/// the end segments go on straight, as in [`Self::sample`].
|
||||
fn sample_at(side: &[Point], knots: &[f64], u: f64) -> Point {
|
||||
if side.len() < 2 || side.len() != knots.len() {
|
||||
return side.first().copied().unwrap_or_default();
|
||||
}
|
||||
let i = knots.partition_point(|k| *k <= u).clamp(1, side.len() - 1) - 1;
|
||||
let (k0, k1) = (knots[i], knots[i + 1]);
|
||||
let f = if k1 > k0 { (u - k0) / (k1 - k0) } else { 0.0 };
|
||||
side[i].lerp(side[i + 1], f)
|
||||
}
|
||||
|
||||
/// The point at `u` along side `which` (0 top, 1 right, 2 bottom, 3 left).
|
||||
fn side_at(&self, which: usize, u: f64) -> Point {
|
||||
let side = match which {
|
||||
0 => &self.top,
|
||||
1 => &self.right,
|
||||
2 => &self.bottom,
|
||||
_ => &self.left,
|
||||
};
|
||||
match self.knots.as_deref().and_then(|k| k.get(which)) {
|
||||
Some(knots) => Self::sample_at(side, knots, u),
|
||||
None => Self::sample(side, u),
|
||||
}
|
||||
}
|
||||
|
||||
/// The surface point at (u, v) ∈ [0,1]².
|
||||
pub fn eval(&self, u: f64, v: f64) -> Point {
|
||||
let (t, b, l, r) = (Self::sample(&self.top, u), Self::sample(&self.bottom, u), Self::sample(&self.left, v), Self::sample(&self.right, v));
|
||||
let (p00, p10, p01, p11) = (self.top[0], self.top[SIDE - 1], self.bottom[0], self.bottom[SIDE - 1]);
|
||||
let (t, b, l, r) = (self.side_at(0, u), self.side_at(2, u), self.side_at(3, v), self.side_at(1, v));
|
||||
let [p00, p10, p11, p01] = self.corners();
|
||||
let c = (1.0 - v) * t.to_vec2() + v * b.to_vec2() + (1.0 - u) * l.to_vec2() + u * r.to_vec2()
|
||||
- ((1.0 - u) * (1.0 - v) * p00.to_vec2() + u * (1.0 - v) * p10.to_vec2() + (1.0 - u) * v * p01.to_vec2() + u * v * p11.to_vec2());
|
||||
c.to_point()
|
||||
@@ -158,7 +316,84 @@ impl Coons {
|
||||
|
||||
/// The four corners TL, TR, BR, BL.
|
||||
pub fn corners(&self) -> [Point; 4] {
|
||||
[self.top[0], self.top[SIDE - 1], self.bottom[SIDE - 1], self.bottom[0]]
|
||||
let end = |side: &[Point]| side.last().copied().unwrap_or_default();
|
||||
[self.top[0], end(&self.top), end(&self.bottom), self.bottom[0]]
|
||||
}
|
||||
}
|
||||
|
||||
/// The most points one side of [`Coons::from_top_object`] holds while it adds points between its
|
||||
/// anchors.
|
||||
const MAX_SIDE_POINTS: usize = 1 << 16;
|
||||
|
||||
/// How far into the outline's bounding box, as a share of its width and of its height, a corner
|
||||
/// 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.
|
||||
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
|
||||
/// smooth, and is not a corner of a top object.
|
||||
const SMOOTH_TURN: f64 = 0.01;
|
||||
|
||||
/// A top object's corner this close (in curve parameter) to either end of its segment moves onto
|
||||
/// the anchor there.
|
||||
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`]
|
||||
/// 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 {
|
||||
let (vin, vout) = (a - h_in, h_out - a);
|
||||
let (len_in, len_out) = (vin.hypot(), vout.hypot());
|
||||
if len_in <= SHORT_HANDLE || len_out <= SHORT_HANDLE {
|
||||
return true;
|
||||
}
|
||||
let (ui, uo) = (vin / len_in, vout / len_out);
|
||||
if (ui.x - uo.x).abs() < SMOOTH_TURN && (ui.y - uo.y).abs() < SMOOTH_TURN {
|
||||
return false;
|
||||
}
|
||||
ui.cross(uo) >= 0.0
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
use vectorcraft_geom::kurbo::ParamCurveNearest;
|
||||
let n = segs.len();
|
||||
if n == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
let (reach_x, reach_y) = (r.width() * CORNER_REACH, r.height() * CORNER_REACH);
|
||||
let mut best: Option<(f64, 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));
|
||||
}
|
||||
}
|
||||
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 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
|
||||
} else if near.t < 0.5 {
|
||||
i as f64
|
||||
} else {
|
||||
((i + 1) % n) as f64
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1033,7 +1268,7 @@ pub(super) fn surface_of<'a>(kind: &'a EnvelopeKind, src: Rect, frame: Affine) -
|
||||
let (rows, cols) = (*rows as usize, *cols as usize);
|
||||
Box::new(move |u: f64, v: f64| grid_eval(rows, cols, points, u, v))
|
||||
}
|
||||
EnvelopeKind::TopObject { path } => match Coons::from_path(path) {
|
||||
EnvelopeKind::TopObject { path, corners } => match Coons::from_top_object(path, *corners) {
|
||||
Some(co) => Box::new(move |u: f64, v: f64| co.eval(u, v)),
|
||||
None => Box::new(flat),
|
||||
},
|
||||
|
||||
@@ -508,7 +508,7 @@ impl Node {
|
||||
*h = (lin * h.to_point()).to_vec2();
|
||||
}
|
||||
}
|
||||
EnvelopeKind::TopObject { path } => path.transform(a),
|
||||
EnvelopeKind::TopObject { path, .. } => path.transform(a),
|
||||
EnvelopeKind::Warp { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make with Top Object",
|
||||
["Object", "Envelope Distort"],
|
||||
Some("Cmd+Alt+C"),
|
||||
"{ids?} the topmost selected path becomes the envelope of the others (type maps from its glyph outline bounds) → {id}",
|
||||
"{ids?} the topmost selected path becomes the envelope of the others: its outline splits into four sides at its corners, and each piece of a side takes a share of the content in proportion to its control polygon's length (type maps from its glyph outline bounds) → {id}",
|
||||
has_multi,
|
||||
env_make_top
|
||||
),
|
||||
|
||||
@@ -113,7 +113,7 @@ pub(super) fn env_make_top(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = s.doc()?.doc.node(top).and_then(outline_of).filter(|p| p.bounds().is_some()).ok_or_else(|| bad(C, "the top object must be a path"))?;
|
||||
let defaults = envelope_defaults(s);
|
||||
let id = s.edit("Make Envelope", |d, sel| {
|
||||
let id = wrap(d, sel, &content, |id, content| envelope(id, content, EnvelopeKind::TopObject { path }, defaults))?;
|
||||
let id = wrap(d, sel, &content, |id, content| envelope(id, content, EnvelopeKind::TopObject { path, corners: None }, defaults))?;
|
||||
d.remove(top)?;
|
||||
Ok(id)
|
||||
})?;
|
||||
@@ -223,7 +223,7 @@ pub(super) fn env_info(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
fn envelope_shape(env: &Node, kind: &EnvelopeKind) -> Option<Node> {
|
||||
let grey = Color::gray(0.25);
|
||||
let (rows, cols) = match kind {
|
||||
EnvelopeKind::TopObject { path } => {
|
||||
EnvelopeKind::TopObject { path, .. } => {
|
||||
return Some(Node::path(NodeId(0), path.clone(), Appearance::basic(Paint::solid(grey), Paint::None, 0.0)));
|
||||
}
|
||||
// The mesh itself, handles and all.
|
||||
|
||||
@@ -537,6 +537,25 @@ fn envelope_top_object_maps_corners() {
|
||||
assert!(close(b.x0, 350.0) && close(b.y1, 250.0));
|
||||
}
|
||||
|
||||
/// Each piece of a top object's side takes a share of the content in proportion to its control
|
||||
/// polygon, spread by its curve parameter. The top side's two curves have polygons of 64.7 and
|
||||
/// 166.5, so the content's middle lands on the second curve at t = 0.3056, (482.6, 106.4); by
|
||||
/// length it would land at (493.7, 106.9), and by equal shares at the anchor (440, 100).
|
||||
#[test]
|
||||
fn envelope_top_object_shares_a_side_by_control_polygon_length() {
|
||||
let mut s = session();
|
||||
let left = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
|
||||
let right = rect(&mut s, 200.0, 100.0, 100.0, 100.0);
|
||||
let d = "M400 100 C410 80 430 80 440 100 C460 110 590 110 600 100 L600 200 L400 200 Z";
|
||||
let t = id_of(&s.execute("path.create", &json!({ "d": d })).unwrap());
|
||||
sel(&mut s, &[left, right, t]);
|
||||
let e = id_of(&s.execute("object.envelope.makeWithTopObject", &json!({})).unwrap());
|
||||
let pts = all_anchors(&live::expand_live(&node(&s, e)));
|
||||
let near = |c: Point| pts.iter().map(|p| p.distance(c)).fold(f64::INFINITY, f64::min);
|
||||
assert!(near(Point::new(482.6, 106.37)) < 0.5, "the middle of the top: {pts:?}");
|
||||
assert!(near(Point::new(500.0, 200.0)) < 0.5, "the middle of the bottom: {pts:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_release_and_expand() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -290,7 +290,9 @@ pub(super) fn live(o: &Obj, before: Option<Node>, inner: Vec<Node>) -> Option<No
|
||||
}
|
||||
3 => {
|
||||
let (rows, cols, pts) = upright(shape?)?;
|
||||
EnvelopeKind::TopObject { path: outline(rows, cols, &pts)? }
|
||||
// The outline runs clockwise from the top left corner, one segment per grid edge.
|
||||
let corners = [0, cols, cols.checked_add(rows)?, cols.checked_mul(2)?.checked_add(rows)?];
|
||||
EnvelopeKind::TopObject { path: outline(rows, cols, &pts)?, corners: Some(corners) }
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
@@ -6831,16 +6831,21 @@ fn a_top_object_envelope_opens_live_with_its_outline() {
|
||||
bp.curve_to((120.0, 80.0), (80.0, 80.0), (40.0, 80.0));
|
||||
bp.curve_to((40.0, 60.0), (40.0, 40.0), (40.0, 20.0));
|
||||
bp.close_path();
|
||||
let want = EnvelopeKind::TopObject { path: vectorcraft_geom::PathData::from_bezpath(&bp) };
|
||||
let want = EnvelopeKind::TopObject { path: vectorcraft_geom::PathData::from_bezpath(&bp), corners: None };
|
||||
let r = read_ok(&layer(&envelope_of(&shape, ENVELOPE_KEYS, &envelope_content(), 3, &drawn_content(&want))));
|
||||
let a = art(&r.doc);
|
||||
assert_eq!(a.len(), 1, "{a:#?}");
|
||||
let (content, kind, ..) = live_envelope(&a[0]);
|
||||
let EnvelopeKind::TopObject { path } = kind else { panic!("{kind:?}") };
|
||||
let EnvelopeKind::TopObject { path, corners } = kind else { panic!("{kind:?}") };
|
||||
let anchors: Vec<Point> = path.subpaths.iter().flat_map(|s| s.anchors.iter().map(|a| a.p)).collect();
|
||||
for p in [(40.0, 20.0), (100.0, 5.0), (160.0, 20.0), (160.0, 80.0), (40.0, 80.0)] {
|
||||
assert!(anchors.iter().any(|q| (*q - Point::from(p)).hypot() < 1e-6), "{p:?} not in {anchors:?}");
|
||||
}
|
||||
// The mesh's four corners are kept as the outline's corners.
|
||||
let corners = corners.expect("the saved corners");
|
||||
let at: Vec<Point> = corners.iter().map(|i| anchors[*i as usize]).collect();
|
||||
let want_corners = [(40.0, 20.0), (160.0, 20.0), (160.0, 80.0), (40.0, 80.0)].map(Point::from);
|
||||
assert!(at.iter().zip(want_corners).all(|(p, q)| (*p - q).hypot() < 1e-6), "{at:?}");
|
||||
// The curve through the anchor: its handles there run level.
|
||||
let mid = path.subpaths.iter().flat_map(|s| s.anchors.iter()).find(|a| (a.p - Point::new(100.0, 5.0)).hypot() < 1e-6).unwrap();
|
||||
assert!((mid.h_in.y - 5.0).abs() < 1e-6 && (mid.h_out.y - 5.0).abs() < 1e-6, "{mid:?}");
|
||||
@@ -7072,7 +7077,7 @@ fn an_envelope_whose_contents_are_edited_opens_live_in_edit_contents() {
|
||||
bp.curve_to((120.0, 80.0), (80.0, 80.0), (40.0, 80.0));
|
||||
bp.curve_to((40.0, 60.0), (40.0, 40.0), (40.0, 20.0));
|
||||
bp.close_path();
|
||||
let want = EnvelopeKind::TopObject { path: vectorcraft_geom::PathData::from_bezpath(&bp) };
|
||||
let want = EnvelopeKind::TopObject { path: vectorcraft_geom::PathData::from_bezpath(&bp), corners: None };
|
||||
let keys = ENVELOPE_KEYS.replace("0 /Bool (AIDeformEditModeKey)", "1 /Bool (AIDeformEditModeKey)");
|
||||
let body = envelope_parts(&envelope_content(), &keys, &format!("0 g\n{mesh}"), 3, &drawn_content(&want));
|
||||
let r = read_ok(&layer(&body));
|
||||
|
||||
@@ -291,7 +291,7 @@ fn precision_rounds_the_paths_objects_keep_besides_their_own() {
|
||||
d.insert(Some(layer), 1, Node::new(id, NodeKind::Blend { children: keys, spec: Box::new(blend) })).unwrap();
|
||||
let envelope = NodeKind::Envelope {
|
||||
content: vec![Arc::new(precise_path(&mut d, 1.0, 1.0))],
|
||||
kind: Box::new(EnvelopeKind::TopObject { path: path() }),
|
||||
kind: Box::new(EnvelopeKind::TopObject { path: path(), corners: None }),
|
||||
fidelity: 50.0,
|
||||
editing: false,
|
||||
options: Default::default(),
|
||||
|
||||
@@ -6616,6 +6616,15 @@ Object → Envelope Distort wraps the selection in a live envelope: `object.enve
|
||||
arcLower, arcUpper, arch, bulge, shellLower, shellUpper, flag, wave, fish, rise, fisheye, inflate, squeeze or twist;
|
||||
`bend`, `h` and `v` in % from -100 to 100; `horizontal` or `orientation`), `object.envelope.makeWithMesh` (`rows`,
|
||||
`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 are
|
||||
sharp corners pointing out of the outline: a handle shorter than 0.01 pt, or tangents whose unit vectors differ by 0.01
|
||||
or more in x or y. 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.
|
||||
`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
|
||||
|
||||
@@ -1472,6 +1472,8 @@ 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 sharp, outward-pointing anchor within 47.5% of the bounding box from its box corner, 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.
|
||||
|
||||
- **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.
|
||||
|
||||
- **Smart Blur modes:** Effect › Blur › Smart Blur… has a Mode menu with Normal, Edge Only and Overlay Edge (`blurMode`: `normal`, `edgeOnly` or `overlayEdge`; an effect saved without it draws as Normal). Edge Only draws the edges in white on black, and Overlay Edge draws them in white over the Normal blur. A pixel is on an edge when one of the pixels the search reads within Radius of it differs from it by more than Threshold in a channel and its channels, alpha included, add up to at least that pixel's. The search reads neighbors as the Normal blur does, kept within Radius, and always the four nearest pixels, so a color step gives a line about as wide as Radius, and at least one pixel wide, on the side with the larger sum (lines on both sides when the sums are equal). The white and the black keep each pixel's coverage. Behavior reference: [Summary of effects](https://helpx.adobe.com/illustrator/using/summary-effects.html).
|
||||
|
||||
Reference in New Issue
Block a user