Envelopes: a top object's side that is one split curve follows its parameter

A top-object envelope gives each piece of a side a share of the content
in proportion to its control polygon's length. When anchors split a
curve without changing its shape, the pieces' polygons don't divide the
side where the curve's own parameter does, so the envelope bent its
contents differently although its outline was the same: Add Anchor
Points on a top object moved the art, and a top object whose .ai mesh
saves each side's curve split into thirds drew its type up to about
half a point off its drawn art, which follows the whole curve's
parameter.

Coons::from_top_object now checks whether a side's pieces, from corner
to corner, are all pieces of one cubic. The pieces' spans of that
curve's parameter come from the handle lengths either side of each
split, the curve from the outer ends and handles, and every piece must
lie within 0.02 pt of the curve's piece over its span (each control
point). Such a side runs along that curve, by its parameter. Any other
side keeps the polygon shares, including a side only partly made of one
curve and a smooth side drawn in many pieces that isn't one curve as a
whole. Corners found from the outline can still move when anchors are
added, as before. Gradient meshes made from a path are unchanged.

Existing top-object envelopes with such sides redraw.
a_split_curve_moves_the_top_object_share_to_its_new_anchor, which
pinned the earlier behaviour, becomes a_split_curve_keeps_the_top_object_patch.

Tests: a_split_curve_keeps_the_top_object_patch,
a_top_object_joins_a_split_curve_saved_with_rounded_coordinates,
a_top_object_keeps_smoothly_joined_curves_apart,
a_top_object_side_partly_of_one_curve_keeps_the_polygon_shares and
a_top_object_side_of_many_arcs_stays_on_them (vectorcraft-doc),
envelope_top_object_keeps_its_look_when_anchors_are_added
(vectorcraft-engine) and
a_top_object_whose_top_is_one_curve_split_in_thirds_opens_live
(vectorcraft-eps).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jmverges
2026-11-13 11:18:40 +00:00
co-authored by Claude Opus 5.5
parent 1dbdbce91f
commit 728fd9ef14
8 changed files with 298 additions and 35 deletions
+125 -14
View File
@@ -568,23 +568,134 @@ 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_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]
+84 -15
View File
@@ -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,67 @@ 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. 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));
let next = spans.last().copied().unwrap_or(1.0) * out / into;
if !(into > 0.0 && out > 0.0 && 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;
+1 -1
View File
@@ -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
),
+26
View File
@@ -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();
+49
View File
@@ -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
View File
@@ -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
@@ -9517,7 +9517,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
@@ -10999,4 +11004,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