mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 22:21:59 +00:00
Merge pull request #7099 from jmverges/fix/7097-top-object-split-curve
Envelopes: a top object's side that is one split curve follows its parameter
This commit is contained in:
+158
-14
@@ -568,23 +568,167 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The outline of a 200 × 100 box whose top side is `pieces`, end to end from (0, 0) to
|
||||
/// (200, 0), and whose other sides are straight.
|
||||
fn top_of_pieces(pieces: &[CubicBez]) -> PathData {
|
||||
let pt = |p: Point| (p.x, p.y);
|
||||
let mut knots: Vec<Knot> = vec![];
|
||||
for (k, c) in pieces.iter().enumerate() {
|
||||
let h_in = if k == 0 { pt(c.p0) } else { pt(pieces[k - 1].p2) };
|
||||
knots.push((pt(c.p0), h_in, pt(c.p1)));
|
||||
}
|
||||
let last = pieces.last().unwrap();
|
||||
knots.push(((200.0, 0.0), pt(last.p2), (200.0, 0.0)));
|
||||
knots.extend([((200.0, 100.0), (200.0, 100.0), (200.0, 100.0)), ((0.0, 100.0), (0.0, 100.0), (0.0, 100.0))]);
|
||||
outline(&knots)
|
||||
}
|
||||
|
||||
/// A top curve whose parameter runs fast at its start and slow at its end.
|
||||
const UNEVEN_TOP: CubicBez =
|
||||
CubicBez { p0: Point::new(0.0, 0.0), p1: Point::new(120.0, -60.0), p2: Point::new(190.0, -30.0), p3: Point::new(200.0, 0.0) };
|
||||
|
||||
#[test]
|
||||
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));
|
||||
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();
|
||||
fn a_split_curve_keeps_the_top_object_patch() {
|
||||
// Splitting the top curve at t = 0.3, or into thirds, keeps its shape, and the patch stays
|
||||
// the unsplit curve's: the new anchors land at u = 0.3, 1/3 and 2/3 (to within how finely
|
||||
// the side is sampled), where the pieces' control polygons would have put them several
|
||||
// points away.
|
||||
let whole = Coons::from_top_object(&top_of_pieces(&[UNEVEN_TOP]), None).unwrap();
|
||||
for cuts in [vec![0.3], vec![1.0 / 3.0, 2.0 / 3.0]] {
|
||||
let ends: Vec<f64> = std::iter::once(0.0).chain(cuts.iter().copied()).chain([1.0]).collect();
|
||||
let pieces: Vec<CubicBez> = ends.windows(2).map(|w| UNEVEN_TOP.subsegment(w[0]..w[1])).collect();
|
||||
let c = Coons::from_top_object(&top_of_pieces(&pieces), None).unwrap();
|
||||
let total: f64 = pieces.iter().map(|p| polygon(*p)).sum();
|
||||
for (k, t) in cuts.iter().enumerate() {
|
||||
let anchor = pieces[k].p3;
|
||||
assert!(c.eval(*t, 0.0).distance(anchor) < 0.02, "{cuts:?}: u {t} → {:?}, want {anchor:?}", c.eval(*t, 0.0));
|
||||
let share: f64 = pieces[..=k].iter().map(|p| polygon(*p)).sum::<f64>() / total;
|
||||
assert!(c.eval(share, 0.0).distance(anchor) > 2.0, "{cuts:?}: the polygon share {share} lands on the anchor");
|
||||
}
|
||||
for (u, v) in [(0.1, 0.0), (0.3, 0.2), (0.5, 0.5), (0.62, 0.0), (0.9, 0.7), (0.97, 1.0)] {
|
||||
let (p, q) = (c.eval(u, v), whole.eval(u, v));
|
||||
assert!(p.distance(q) < 1e-9, "{cuts:?}: ({u}, {v}) → {p:?}, unsplit {q:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_split_curve_keeps_the_top_object_patch_when_split_at_a_cusp() {
|
||||
// The top curve stops at t = 0.25 (a cusp), and an anchor splits it there: both handles at
|
||||
// the split lie on the anchor, and the patch with the corners given stays the unsplit
|
||||
// curve's.
|
||||
let cusp = CubicBez::new((0.0, 0.0), (30.0, -40.0), (-58.0, 24.0), (200.0, 0.0));
|
||||
let pieces = [cusp.subsegment(0.0..0.25), cusp.subsegment(0.25..1.0)];
|
||||
assert!(pieces[0].p2.distance(pieces[0].p3) < 1e-9 && pieces[1].p1.distance(pieces[1].p0) < 1e-9);
|
||||
let whole = Coons::from_top_object(&top_of_pieces(&[cusp]), Some([0, 1, 2, 3])).unwrap();
|
||||
let c = Coons::from_top_object(&top_of_pieces(&pieces), Some([0, 2, 3, 4])).unwrap();
|
||||
for (u, v) in [(0.1, 0.0), (0.25, 0.0), (0.3, 0.2), (0.5, 0.5), (0.62, 0.0), (0.9, 0.7), (0.97, 1.0)] {
|
||||
let (p, q) = (c.eval(u, v), whole.eval(u, v));
|
||||
assert!(p.distance(q) < 1e-6, "({u}, {v}) → {p:?}, unsplit {q:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_split_curve_keeps_the_top_object_patch_where_it_barely_moves_and_runs_straight() {
|
||||
// A straight top curve that barely moves at t = 0.25 and doesn't bend there: split there,
|
||||
// the handles at the split are a few thousandths of a point long and the pieces don't bend
|
||||
// at all, and the patch with the corners given stays the unsplit curve's.
|
||||
let slow = CubicBez::new((0.0, 0.0), (28.578125, 0.0), (-57.109375, 0.0), (200.0, 0.0));
|
||||
let pieces = [slow.subsegment(0.0..0.25), slow.subsegment(0.25..1.0)];
|
||||
let (into, out) = (pieces[0].p3.distance(pieces[0].p2), pieces[1].p0.distance(pieces[1].p1));
|
||||
assert!(into > 0.0 && out > 0.0 && into.max(out) < 0.01, "{into} {out}");
|
||||
let whole = Coons::from_top_object(&top_of_pieces(&[slow]), Some([0, 1, 2, 3])).unwrap();
|
||||
let c = Coons::from_top_object(&top_of_pieces(&pieces), Some([0, 2, 3, 4])).unwrap();
|
||||
for (u, v) in [(0.1, 0.0), (0.25, 0.0), (0.3, 0.2), (0.5, 0.5), (0.62, 0.0), (0.9, 0.7), (0.97, 1.0)] {
|
||||
let (p, q) = (c.eval(u, v), whole.eval(u, v));
|
||||
assert!(p.distance(q) < 1e-6, "({u}, {v}) → {p:?}, unsplit {q:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_top_object_joins_a_split_curve_saved_with_rounded_coordinates() {
|
||||
// A file saves the pieces of a curve split into thirds, or into tenths, with its
|
||||
// coordinates rounded to 0.001 pt, on an outline 5, 200 or 5,000 pt wide: the side is
|
||||
// still that curve, to within 0.02 pt.
|
||||
let round = |p: Point| Point::new((p.x * 1000.0).round() / 1000.0, (p.y * 1000.0).round() / 1000.0);
|
||||
for cuts in [3u32, 10] {
|
||||
let pieces: Vec<CubicBez> =
|
||||
(0..cuts).map(|k| UNEVEN_TOP.subsegment(f64::from(k) / f64::from(cuts)..f64::from(k + 1) / f64::from(cuts))).collect();
|
||||
for scale in [0.025, 1.0, 25.0] {
|
||||
let mut split = top_of_pieces(&pieces);
|
||||
split.transform(Affine::scale(scale));
|
||||
for a in split.subpaths.iter_mut().flat_map(|sp| sp.anchors.iter_mut()) {
|
||||
(a.p, a.h_in, a.h_out) = (round(a.p), round(a.h_in), round(a.h_out));
|
||||
}
|
||||
let mut whole = top_of_pieces(&[UNEVEN_TOP]);
|
||||
whole.transform(Affine::scale(scale));
|
||||
let (c, whole) = (Coons::from_top_object(&split, None).unwrap(), Coons::from_top_object(&whole, None).unwrap());
|
||||
for (u, v) in [(1.0 / 6.0, 0.0), (1.0 / 3.0, 0.0), (0.45, 0.0), (0.5, 0.5), (2.0 / 3.0, 0.0), (0.9, 0.3)] {
|
||||
let (p, q) = (c.eval(u, v), whole.eval(u, v));
|
||||
assert!(p.distance(q) < 0.02, "{cuts} pieces at {scale}x: ({u}, {v}) → {p:?}, unsplit {q:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_top_object_keeps_smoothly_joined_curves_apart() {
|
||||
// The top side's second piece leaves the split anchor in line with the first piece's
|
||||
// handle, but its far handle is 1 pt off the split curve's: the pieces are two curves, and
|
||||
// each takes the share of its control polygon.
|
||||
let (a, b) = (UNEVEN_TOP.subsegment(0.0..0.3), UNEVEN_TOP.subsegment(0.3..1.0));
|
||||
let b = CubicBez::new(b.p0, b.p1, b.p2 + Vec2::new(0.0, 1.0), b.p3);
|
||||
let c = Coons::from_top_object(&top_of_pieces(&[a, b]), 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}");
|
||||
assert!((share - 0.3).abs() > 0.02, "{share}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_top_object_side_partly_of_one_curve_keeps_the_polygon_shares() {
|
||||
// The top side is a curve split at t = 0.3, then a straight segment: not all of it is one
|
||||
// curve, so each piece keeps the share of its control polygon.
|
||||
let curve = CubicBez::new((0.0, 0.0), (90.0, -45.0), (142.5, -22.5), (150.0, 0.0));
|
||||
let (a, b) = (curve.subsegment(0.0..0.3), curve.subsegment(0.3..1.0));
|
||||
let line = CubicBez::new((150.0, 0.0), (150.0 + 50.0 / 3.0, 0.0), (200.0 - 50.0 / 3.0, 0.0), (200.0, 0.0));
|
||||
let c = Coons::from_top_object(&top_of_pieces(&[a, b, line]), None).unwrap();
|
||||
let share = polygon(a) / (polygon(a) + polygon(b) + polygon(line));
|
||||
assert!(c.eval(share, 0.0).distance(a.p3) < 1e-9, "{:?}", c.eval(share, 0.0));
|
||||
// Joined into their curve, the two pieces would have put the anchor 0.3 of the way along
|
||||
// their part of the side.
|
||||
let joined = 0.3 * (polygon(a) + polygon(b)) / (polygon(a) + polygon(b) + polygon(line));
|
||||
assert!(c.eval(joined, 0.0).distance(a.p3) > 1.0, "{share} vs {joined}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_top_object_side_of_many_arcs_stays_on_them() {
|
||||
// A 120° arc of radius 500 drawn as `n` circular arcs, each close to a piece of one cubic
|
||||
// while the whole arc is not one: the side stays on the circle, and the content's middle
|
||||
// stays on the middle anchor, as the arcs' polygon shares put it.
|
||||
let centre = Point::new(300.0, 600.0);
|
||||
for n in [64u32, 128, 256] {
|
||||
let step = 120f64.to_radians() / f64::from(n);
|
||||
let reach = 500.0 * 4.0 / 3.0 * (step / 4.0).tan();
|
||||
let on = |a: f64| centre + Vec2::new(a.cos(), a.sin()) * 500.0;
|
||||
let tangent = |a: f64| Vec2::new(-a.sin(), a.cos()) * reach;
|
||||
let mut anchors: Vec<Anchor> = (0..=n)
|
||||
.map(|i| {
|
||||
let a = (-150f64).to_radians() + step * f64::from(i);
|
||||
let p = on(a);
|
||||
let h_in = if i == 0 { p } else { p - tangent(a) };
|
||||
let h_out = if i == n { p } else { p + tangent(a) };
|
||||
Anchor { p, h_in, h_out, ..Anchor::corner(p) }
|
||||
})
|
||||
.collect();
|
||||
let (start, end) = (on((-150f64).to_radians()), on((-30f64).to_radians()));
|
||||
anchors.extend([Anchor::corner(Point::new(end.x, 800.0)), Anchor::corner(Point::new(start.x, 800.0))]);
|
||||
let path = PathData::single(SubPath::new(anchors, true));
|
||||
let c = Coons::from_top_object(&path, Some([0, n, n + 1, n + 2])).unwrap();
|
||||
let off = (0..=4000).map(|k| (c.eval(f64::from(k) / 4000.0, 0.0).distance(centre) - 500.0).abs()).fold(0.0, f64::max);
|
||||
assert!(off < 0.001, "{n} arcs: the side lies {off} pt off the circle");
|
||||
let mid = c.eval(0.5, 0.0);
|
||||
assert!(mid.distance(Point::new(300.0, 100.0)) < 0.001, "{n} arcs: the middle lands at {mid:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+93
-15
@@ -147,14 +147,18 @@ 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 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.
|
||||
/// curve parameter. A side whose pieces, from corner to corner, are all one cubic curve split at
|
||||
/// anchors (each within 0.02 pt of that curve's piece) runs along that curve instead, by its
|
||||
/// parameter: splitting the curve of such a side at more anchors, without changing its shape,
|
||||
/// leaves how the side shares out the content as it was (corners found from the outline can
|
||||
/// still move). 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();
|
||||
@@ -212,11 +216,12 @@ impl Coons {
|
||||
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.
|
||||
// through pieces of segments, split at `a` and `b`; when all of them are one curve split
|
||||
// at anchors ([`one_curve`]), it runs along that curve instead. 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) {
|
||||
@@ -232,8 +237,11 @@ impl Coons {
|
||||
}
|
||||
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 pieces = match one_curve(&pieces) {
|
||||
Some(one) => vec![one],
|
||||
None => pieces,
|
||||
};
|
||||
let mut weights: Vec<f64> = pieces.iter().map(control_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);
|
||||
@@ -336,6 +344,76 @@ impl Coons {
|
||||
/// anchors.
|
||||
const MAX_SIDE_POINTS: usize = 1 << 16;
|
||||
|
||||
/// How far, in points, each control point of each piece of a top object's side may lie from the
|
||||
/// matching piece of one cubic curve for the side to run along that curve ([`one_curve`]); the side
|
||||
/// then lies within this of the pieces it is drawn with. A curve split into thirds or tenths and
|
||||
/// saved with its coordinates rounded to 0.001 pt stays within it, on outlines from 5 to 5,000 pt
|
||||
/// wide; coarser rounding, or a piece of a much smaller share of the curve, can take it past this.
|
||||
/// A smooth side that isn't one curve, such as an arc of a circle drawn in many pieces, lies
|
||||
/// further off as a whole, even where each pair of its pieces would not.
|
||||
const ONE_CURVE: f64 = 0.02;
|
||||
|
||||
/// The length of `c`'s control polygon.
|
||||
fn control_polygon(c: &CubicBez) -> f64 {
|
||||
c.p0.distance(c.p1) + c.p1.distance(c.p2) + c.p2.distance(c.p3)
|
||||
}
|
||||
|
||||
/// The cubic curve that `pieces`, two or more end to end, are all pieces of, split at the anchors
|
||||
/// between them, when each control point of each piece lies within [`ONE_CURVE`] of the matching
|
||||
/// piece of that curve; `None` otherwise. Split at parameter `t`, a curve's handles on either side
|
||||
/// of the split lie in line, their lengths in proportion to the spans of its parameter the two
|
||||
/// pieces take; split where the curve stops (a cusp), both handles lie on the anchor, and the
|
||||
/// pieces' second derivatives there, in proportion to the squares of the spans, give the proportion
|
||||
/// instead (whichever of the two pairs is the longer). Those proportions give each piece's span, and the curve follows from the first
|
||||
/// piece's start and its handle, and the last piece's end and its handle, scaled up by their spans.
|
||||
/// Every piece is then checked against that curve's piece over its span, so error can't build up
|
||||
/// from one split to the next (the first piece's start and handle, and the last piece's end and
|
||||
/// handle, match by construction).
|
||||
fn one_curve(pieces: &[CubicBez]) -> Option<CubicBez> {
|
||||
let (first, last) = (pieces.first()?, pieces.last()?);
|
||||
if pieces.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let mut spans = Vec::with_capacity(pieces.len());
|
||||
spans.push(1.0);
|
||||
for pair in pieces.windows(2) {
|
||||
let [a, b] = pair else { return None };
|
||||
let (into, out) = (a.p3.distance(a.p2), b.p0.distance(b.p1));
|
||||
// How far each piece bends away from the anchor goes with the squares of the spans. Split
|
||||
// where the curve stops (a cusp), both handles lie on the anchor and say nothing of the
|
||||
// spans, and the bends say it; where the curve runs straight through the split, the bends
|
||||
// are nothing and the handles say it. Whichever pair is the longer is the one to go by.
|
||||
let bend_in = ((a.p3 - a.p2) - (a.p2 - a.p1)).hypot();
|
||||
let bend_out = ((b.p2 - b.p1) - (b.p1 - b.p0)).hypot();
|
||||
let ratio = if into.min(out) >= bend_in.min(bend_out) { out / into } else { (bend_out / bend_in).sqrt() };
|
||||
let next = spans.last().copied().unwrap_or(1.0) * ratio;
|
||||
if !(next.is_finite() && next > 0.0) {
|
||||
return None;
|
||||
}
|
||||
spans.push(next);
|
||||
}
|
||||
let total: f64 = spans.iter().sum();
|
||||
let (head, tail) = (spans.first().copied().unwrap_or(1.0) / total, spans.last().copied().unwrap_or(1.0) / total);
|
||||
if !(total.is_finite() && head > 0.0 && tail > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let one = CubicBez::new(first.p0, first.p0 + (first.p1 - first.p0) / head, last.p3 + (last.p2 - last.p3) / tail, last.p3);
|
||||
if ![one.p1, one.p2].iter().all(|p| p.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let near = |p: Point, q: Point| p.distance(q) <= ONE_CURVE;
|
||||
let mut from = 0.0;
|
||||
for (k, (piece, span)) in pieces.iter().zip(&spans).enumerate() {
|
||||
let to = if k + 1 == pieces.len() { 1.0 } else { (from + span / total).min(1.0) };
|
||||
let part = one.subsegment(from..to);
|
||||
if !(near(part.p0, piece.p0) && near(part.p1, piece.p1) && near(part.p2, piece.p2) && near(part.p3, piece.p3)) {
|
||||
return None;
|
||||
}
|
||||
from = to;
|
||||
}
|
||||
Some(one)
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
@@ -172,7 +172,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: 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}",
|
||||
"{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, and a side whose pieces are all one curve split at anchors follows that curve's parameter (type maps from its glyph outline bounds) → {id}",
|
||||
has_multi,
|
||||
env_make_top
|
||||
),
|
||||
|
||||
@@ -643,6 +643,32 @@ fn envelope_top_object_shares_a_side_by_control_polygon_length() {
|
||||
assert!(near(Point::new(500.0, 200.0)) < 0.5, "the middle of the bottom: {pts:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_top_object_keeps_its_look_when_anchors_are_added() {
|
||||
// Add Anchor Points splits each segment of the top object in two without changing its shape.
|
||||
// The top curve's parameter runs fast at its start, so its halves have control polygons of
|
||||
// different lengths; the envelope still bends the two rectangles as the unsplit curve does.
|
||||
let made = |add_anchors: bool| {
|
||||
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 C520 40 590 70 600 100 L600 200 L400 200 Z";
|
||||
let t = id_of(&s.execute("path.create", &json!({ "d": d })).unwrap());
|
||||
if add_anchors {
|
||||
sel(&mut s, &[t]);
|
||||
s.execute("object.path.addAnchorPoints", &json!({})).unwrap();
|
||||
assert_eq!(node(&s, t).path_data().map(|p| p.anchor_count()), Some(8));
|
||||
}
|
||||
sel(&mut s, &[left, right, t]);
|
||||
let e = id_of(&s.execute("object.envelope.makeWithTopObject", &json!({})).unwrap());
|
||||
all_anchors(&live::expand_live(&node(&s, e)))
|
||||
};
|
||||
let (plain, split) = (made(false), made(true));
|
||||
assert_eq!(plain.len(), split.len());
|
||||
let off = plain.iter().zip(&split).map(|(p, q)| p.distance(*q)).fold(0.0, f64::max);
|
||||
assert!(off < 1e-6, "the art moves by up to {off} pt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_release_and_expand() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -7730,6 +7730,55 @@ fn a_top_object_envelope_opens_live_with_its_outline() {
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// A top object whose saved grid has three patches across, its top edge one curve split into
|
||||
/// thirds, maps its contents along that one curve. In this synthetic file the drawn art is drawn by
|
||||
/// VectorCraft's own map of the unsplit curve, so the envelope opens live only when the split side
|
||||
/// maps the same way, and the contents' first quarter ends a quarter of the way along the curve.
|
||||
#[test]
|
||||
fn a_top_object_whose_top_is_one_curve_split_in_thirds_opens_live() {
|
||||
use kurbo::{CubicBez, ParamCurve};
|
||||
use vectorcraft_doc::live::EnvelopeKind;
|
||||
// In the document: the top runs from (40, 20) to (160, 20), its parameter fast at the start.
|
||||
let top = CubicBez::new((40.0, 20.0), (112.0, -16.0), (154.0, 2.0), (160.0, 20.0));
|
||||
let thirds = [top.subsegment(0.0..1.0 / 3.0), top.subsegment(1.0 / 3.0..2.0 / 3.0), top.subsegment(2.0 / 3.0..1.0)];
|
||||
// Art space has y up: row 1 is the top. The bottom row and the columns are straight.
|
||||
let up = |p: Point| (p.x, 100.0 - p.y);
|
||||
let at = move |(i, j): (usize, usize)| {
|
||||
if j == 1 { up(if i == 0 { top.p0 } else { thirds[i - 1].p3 }) } else { (40.0 + 40.0 * i as f64, 20.0) }
|
||||
};
|
||||
let handle = move |p: (usize, usize), q: (usize, usize)| match (p, q) {
|
||||
((i, 1), (k, 1)) if k == i + 1 => up(thirds[i].p1),
|
||||
((i, 1), (k, 1)) if k + 1 == i => up(thirds[k].p2),
|
||||
_ => {
|
||||
let ((x, y), (u, v)) = (at(p), at(q));
|
||||
(x + (u - x) / 3.0, y + (v - y) / 3.0)
|
||||
}
|
||||
};
|
||||
let shape = mesh_text(3, 1, &at, &handle, None);
|
||||
// The contents: two red bars at the box's left and right ends, drawn by the unsplit curve.
|
||||
let mut bp = kurbo::BezPath::new();
|
||||
bp.move_to(top.p0);
|
||||
bp.curve_to(top.p1, top.p2, top.p3);
|
||||
bp.line_to((160.0, 80.0));
|
||||
bp.line_to((40.0, 80.0));
|
||||
bp.close_path();
|
||||
let one_curve = EnvelopeKind::TopObject { path: vectorcraft_geom::PathData::from_bezpath(&bp), corners: Some([0, 1, 2, 3]) };
|
||||
let src = Rect::new(40.0, 20.0, 160.0, 80.0);
|
||||
let bars = [Rect::new(40.0, 20.0, 70.0, 80.0), Rect::new(130.0, 20.0, 160.0, 80.0)];
|
||||
let content: String = bars.iter().map(|b| format!("{RED}{}f\n", rect_path(b.x0, b.y0, b.x1, b.y1))).collect();
|
||||
let drawn: String = bars.iter().map(|b| drawn_box(&one_curve, src, *b)).collect();
|
||||
let r = read_ok(&layer(&envelope_of(&shape, ENVELOPE_KEYS, &content, 3, &drawn)));
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
let a = art(&r.doc);
|
||||
assert_eq!(a.len(), 1, "{a:#?}");
|
||||
let (_, kind, ..) = live_envelope(&a[0]);
|
||||
let EnvelopeKind::TopObject { path, .. } = kind else { panic!("{kind:?}") };
|
||||
assert_eq!(path.anchor_count(), 8, "{path:?}");
|
||||
let map = vectorcraft_doc::live::envelope_mapper(kind, src);
|
||||
let (p, want) = (map(Point::new(70.0, 20.0)), top.eval(0.25));
|
||||
assert!((p - want).hypot() < 0.02, "{p:?}, want {want:?}");
|
||||
}
|
||||
|
||||
/// Envelope Options come from the options word, Fidelity from its own key.
|
||||
#[test]
|
||||
fn an_envelope_reads_its_options_and_fidelity() {
|
||||
|
||||
File diff suppressed because one or more lines are too long
+8
-2
@@ -1,6 +1,6 @@
|
||||
# VectorCraft MCP server
|
||||
|
||||
> **Last updated:** 2026-11-11 · **Change:** Save location-refusal error (#6146); existing response shapes retained.
|
||||
> **Last updated:** 2026-11-13 · **Change:** Envelopes: a top object's side whose pieces are all one curve split at anchors follows that curve's parameter (#7097); existing response shapes retained. Previously: Save location-refusal error (#6146); existing response shapes retained.
|
||||
|
||||
`vectorcraft-cli mcp` runs a [Model Context Protocol](https://modelcontextprotocol.io) server on stdio
|
||||
(newline-delimited JSON-RPC 2.0; see [Protocol](#protocol) for version negotiation). Agents use it to
|
||||
@@ -9525,7 +9525,12 @@ outline. On a tie, the anchor met first along the outline in the order it was dr
|
||||
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
|
||||
its curve parameter, so content spreads evenly along a segment without handles. A side whose pieces, from corner to
|
||||
corner, are all one cubic curve split at anchors (each within 0.02 pt of that curve's piece) follows that curve's
|
||||
parameter instead, so splitting such a side's curve without changing its shape (Add Anchor Points, or a file that
|
||||
saves a curve in pieces) leaves how the side shares out the content as it was. Corners found from the outline can
|
||||
still move, since one at the outline's nearest point snaps onto an anchor within 0.5% of its segment's parameter. A
|
||||
side only partly made of one curve keeps the polygon shares. 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.
|
||||
A warp bends the content's bounds as one Bézier patch. The style sets the patch's control points from the bend and
|
||||
@@ -11007,4 +11012,5 @@ the error text. The interactive fallback applies only to ordinary File > Save, n
|
||||
|
||||
| Date | Change |
|
||||
|---|---|
|
||||
| 2026-11-13 | Envelopes: a top object's side whose pieces are all one curve split at anchors follows that curve's parameter (#7097). |
|
||||
| 2026-11-11 | Document READ_ONLY_LOCATION for saves (#6146); no response-shape change. |
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user