From 728fd9ef1427feb2bb05f31028d506535f4b7e5f Mon Sep 17 00:00:00 2001 From: jmverges Date: Fri, 13 Nov 2026 06:02:02 +0000 Subject: [PATCH 1/3] 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 --- crates/doc/src/live.rs | 139 ++++++++++++++++++++++--- crates/doc/src/live/mesh.rs | 99 +++++++++++++++--- crates/engine/src/cmd/live/commands.rs | 2 +- crates/engine/src/tests/live.rs | 26 +++++ crates/eps/src/import/ai/tests.rs | 49 +++++++++ docs/file-format-parity.md | 3 +- docs/mcp.md | 10 +- docs/target-app-parity.md | 5 +- 8 files changed, 298 insertions(+), 35 deletions(-) diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index 39fe8cbc43..e667f2972d 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -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 = 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 = std::iter::once(0.0).chain(cuts.iter().copied()).chain([1.0]).collect(); + let pieces: Vec = 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::() / 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 = + (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 = (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] diff --git a/crates/doc/src/live/mesh.rs b/crates/doc/src/live/mesh.rs index d912479464..5e90a56462 100644 --- a/crates/doc/src/live/mesh.rs +++ b/crates/doc/src/live/mesh.rs @@ -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 { 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 = pieces.iter().map(polygon).collect(); + let pieces = match one_curve(&pieces) { + Some(one) => vec![one], + None => pieces, + }; + let mut weights: Vec = 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 { + 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; diff --git a/crates/engine/src/cmd/live/commands.rs b/crates/engine/src/cmd/live/commands.rs index 8a92d759f9..692ce9e563 100644 --- a/crates/engine/src/cmd/live/commands.rs +++ b/crates/engine/src/cmd/live/commands.rs @@ -172,7 +172,7 @@ pub fn specs() -> Vec { "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 ), diff --git a/crates/engine/src/tests/live.rs b/crates/engine/src/tests/live.rs index 10939d4b2a..c372bcf784 100644 --- a/crates/engine/src/tests/live.rs +++ b/crates/engine/src/tests/live.rs @@ -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(); diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 3c0ac39a05..9b86f38350 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -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() { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 0ee82f2cbf..f752a8973b 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: `.ai` and EPS import: type from the text document, type a PostScript program sets and legacy `.ai` type are set in an installed face of their font's PostScript name, exact name first, in the version a text document's record keeps; the `.ai` save writes a static font's version when its name alone gives another copy, #6983; `.ai` and EPS import: type keeps the manual kerning of its text document, #6899; `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, its strokes' miter tips and strokes a Transform effect moves included, after its glyphs grown by half its strokes' weight, #6824; `.ai` and EPS import: type filled or stroked with a pattern swatch keeps its pattern, from the art style its text document paint names, #6856; #6865: explicitly requested oversized editing artboard; PDF import: Indic type keeps its marks on their line, and a line whose type would not draw as the file does keeps the file's glyphs as outlines, #5235; previously: newly imported SVG/SVGZ titles are filtered and bounded without changing restored titles, #3626; `.ai` and EPS import, and what every export and `.ai` save draw: type's strokes in an envelope paint at their own weight along its bent outlines, Distort Appearance on or off, #6644; `.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (`.ai` and EPS import: a top-object envelope whose mesh saves its sides' curves in pieces draws its contents along each whole curve, as its drawn art does, #7097; previously: PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: `.ai` and EPS import: type from the text document, type a PostScript program sets and legacy `.ai` type are set in an installed face of their font's PostScript name, exact name first, in the version a text document's record keeps; the `.ai` save writes a static font's version when its name alone gives another copy, #6983; `.ai` and EPS import: type keeps the manual kerning of its text document, #6899; `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, its strokes' miter tips and strokes a Transform effect moves included, after its glyphs grown by half its strokes' weight, #6824; `.ai` and EPS import: type filled or stroked with a pattern swatch keeps its pattern, from the art style its text document paint names, #6856; #6865: explicitly requested oversized editing artboard; PDF import: Indic type keeps its marks on their line, and a line whose type would not draw as the file does keeps the file's glyphs as outlines, #5235; previously: newly imported SVG/SVGZ titles are filtered and bounded without changing restored titles, #3626; `.ai` and EPS import, and what every export and `.ai` save draw: type's strokes in an envelope paint at their own weight along its bent outlines, Distort Appearance on or off, #6644; `.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) > > Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). @@ -324,6 +324,7 @@ Stop opacity on translated artboards remains outside this regression coverage. | Date | Change | Summary | |---|---|---| | 2026-11-13 | minor | PDF import: glyphs inside a marked-content span with an empty `ActualText` come in as outlines in their fill and stroke instead of being left out, adding no text (#6971; `empty_actual_text_keeps_the_glyphs_art` in `crates/pdf/src/tests/actual_text.rs`). Scores and estimates unchanged. | +| 2026-11-13 | minor | `.ai` and EPS import, and what the canvas, every export and `.ai` save draw: a top-object envelope's mesh can save a side's curve in pieces. In users' files (used locally, never committed), 10 top objects save a grid of three patches across whose top and bottom edges are each one cubic split into thirds, and their drawn art follows the whole curve's parameter. VectorCraft gave each piece the share of its control polygon, so their type came out up to about half a point off the drawn art along the side, and one envelope of type kept its drawn art instead of opening live. A side whose pieces are all one curve split at anchors (each within 0.02 pt of that curve's piece) now follows that curve's parameter (#7097). Synthetic test: `a_top_object_whose_top_is_one_curve_split_in_thirds_opens_live`. Scores unchanged. | | 2026-11-12 | minor | `.ai` and EPS import (#6983): type from the text document is set in an installed face of the PostScript name its font record gives, one of exactly that name first. That face's family is no longer swapped for another family spelled alike but for case and punctuation (`FontDb::find_family`, which picked `SAMPLE INK` for `SampleInk`, the PostScript name of `Sample/Ink`); among the faces `FontDb::postscript_faces` lists for the name, the import takes one whose name matches in case and punctuation before one that matches only without them; and when the family and style alone resolve to another installed copy, the type names the face's version. A font record's key 5, the version string (name ID 5), picks that version among the faces of its PostScript name, also when the record spells the name with other punctuation (`Sample-Slab` for faces named `SampleSlab`); each record is resolved once. Type a PostScript program sets and legacy `.ai` type take the face of their font's name the same way. The `.ai` save writes a static font's version (`/5`) when its PostScript name alone would give the reader another installed copy (another version, or the same name in another family), so two such copies are two fonts of the set, and checks that the type comes back in the same family, style and face, taking the record's version as the reader does (up to 256 bytes): such type round-trips without the type attributes note, and so does a version that isn't installed, which draws in the face the font resolves to anyway. In 224 users' files (used locally, never committed; all PDF-compatible, so the PostScript and legacy paths are not exercised), 16 files set some type in another installed face; 12 render differently against their own page, 10 closer (in one from 2.55% to 0.01% of the page differing) and 2 further by 0.03% of the page (an envelope of type that now opens live, and a copy of the same widths in the version the record names); envelopes of type that open live go from 154 to 159, none fewer in any file; no file opens in another way. The notes of how far a file's layers differ from its page shrink in 2 files and appear in 2 where an envelope of type now opens live, so the file is compared with its page. Two installed copies of one PostScript name, family and style that differ in widths, in files whose records keep no version, still take the copy the family and style resolve to: only the page's widths tell them apart. Tests: `tests_native::type_from_the_text_document_takes_the_face_of_its_exact_postscript_name`, `tests_native::type_from_the_text_document_takes_the_version_its_font_record_keeps`, `tests_fontnames::shown_type_takes_the_face_of_its_exact_postscript_name`, `ai::tests::legacy_type_takes_the_face_of_its_exact_postscript_name`, `ai_editing::tests::type_in_a_font_beside_a_family_spelled_alike_keeps_its_font`, `ai_editing::tests::type_in_another_version_of_a_static_font_keeps_it`, `ai_editing::tests::type_in_a_copy_its_postscript_name_alone_doesnt_give_keeps_it`, `ai_editing::tests::a_version_longer_than_the_reader_takes_is_reported`, `vectorcraft-text` `tests::versions::every_face_of_a_postscript_name_is_found`. Scores unchanged. | | 2026-11-12 | minor | `.ai` and EPS import: type from the text document keeps its manual kerning (#6899). Key 8 of a story's text holds it as runs of characters (key 1), each with a value (key 0 of key 0, in thousandths of an em) or empty where the automatic kerning applies; a character with a value takes it as its Kerning, the space between it and the next character ([Line and character spacing](https://helpx.adobe.com/illustrator/using/line-character-spacing.html)), of the em as its Horizontal Scale draws it, as tracking. It wasn't read, so type kerned by hand came in spaced by the font alone and drifted from the page from each kerned pair on, and envelopes of such type drew apart from their drawn art and kept it. In users' files, used locally and never committed, 48 stories in 17 of 224 files have it, and the drawn art of envelopes of such type sets each kerned gap wider or narrower by exactly that value (a heading's first pair at -140, the letters of a line at 20 to 220). Measured against each file's own page, 14 of those files draw closer (in 4 the pixels that differ go from 2.7–4.7% to under 0.6%) and 2 a little further, both where an envelope of type now opens live in place of its drawn art; envelopes of type that open live go from 154 to 159 of 203, none fewer in any file, and two notes that the type differs from the page go. A value that can't be read keeps the automatic kerning, a run whose length can't be read ends the runs there, and past 65,536 runs the characters keep the automatic kerning. `.ai` save still leaves kerning out of the editing data, so saving such a file as `.ai` again now gives the note that attributes of type are left out for type with manual kerning. Tests `ate::tests::a_story_keeps_its_manual_kerning`, `tests_native::type_keeps_its_manual_kerning`; scores unchanged | | 2026-11-12 | minor | `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, after the ways it was tried before (its glyphs alone and grown by half its strokes' weight, #6535, on its own warp and on its saved grid), before a grid moved onto its drawn art, and only where that box is another: its glyph outlines with their fills and strokes baked as the envelope bakes them (geometry effects on them applied: a stroke a Transform effect moves counts where it is moved to), each piece measured by its visual bounds, which are exact for miter joins (a tip counts where the miter limit keeps it, not the miter allowance at every corner that the bounds with strokes take) and round joins and an upper bound for bevel joins, miters the limit cuts, width profiles and square-capped dashes; a contour of zero length, which paints nothing, doesn't count. Effects on the type object itself are left out, as VectorCraft applies them after the envelope bends the type. The box is measured only when that try comes and the import has a check left for it, and once for an envelope. Groups round type and other objects in the contents are measured as before. In users' files (used locally, never committed) the drawn art of some stroked type in an envelope maps from that box: where the type has sharp corners at its edges (slanted stems, pointed feet) and miter joins, or a stroke moved by a Transform effect (a drop shadow), its glyphs grown by half the weight gave too small a box and the envelope kept its drawn art; and where a glyph holds a contour of zero length at its edge, the glyphs' box counted it. On 224 of our own files: envelopes of type that open live go from 154 in 45 files to 161 in 50, and no file loses one; no file changes whether it opens from its layers or its PDF part; renders at 40 ppi change in 6 files, by at most 0.94% of their pixels, each where an envelope of type now opens live in place of its drawn art (the page's own art): 1 closer to the file's own page and 5 further; the notes change in the same 6 files: the note on type in an envelope goes where none keeps its drawn art, and a file whose envelopes of type all kept their drawn art, which opened without the page comparison, now opens with it and carries its notes (the share of art or type that differs from the page, type the page has without a text object, the PDF part's note on DeviceN colours). A synthetic file of 256 such envelopes of 2,000 characters each opens in the same time as before (13.3 s in a debug build): the box is measured only when its try comes. Tests `ai::envelope::tests::mitred_type_is_also_tried_mapped_from_what_its_stroke_paints_miter_tips_included`, `type_with_a_mitred_character_stroke_is_also_tried_mapped_from_what_it_paints`, `a_stroke_moved_by_a_transform_effect_counts_where_it_paints`, `round_joined_type_is_not_tried_twice_for_the_same_box`, `a_contour_of_zero_length_paints_nothing`, `a_side_within_the_slack_of_the_geometric_box_is_that_side`, `tests_native::a_warp_of_mitred_type_maps_from_what_its_stroke_paints_where_its_drawn_art_does` (#6824, part of #3179) | diff --git a/docs/mcp.md b/docs/mcp.md index 274db05a34..bfb0bde319 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f04ee3c910..549584ed7f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (Graphic Styles list fills a taller panel, #2992; scores and estimates unchanged; previously: PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously minor: Object › Rasterize with the Grayscale or Bitmap colour model makes a grayscale image, #6969; scores and estimates unchanged; previously minor: `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, miter tips and moved strokes included, after its glyphs grown by half its strokes' weight (#6824; #6617: Swatches panel menu: Show Recent Colors; scores unchanged; previously minor: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (Envelopes: a top object's side whose pieces are all one curve split at anchors follows that curve's parameter, so a top object saved with its sides' curves in pieces draws as its `.ai` drawn art, #7097; scores and estimates unchanged; previously: Graphic Styles list fills a taller panel, #2992; scores and estimates unchanged; previously: PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously minor: Object › Rasterize with the Grayscale or Bitmap colour model makes a grayscale image, #6969; scores and estimates unchanged; previously minor: `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, miter tips and moved strokes included, after its glyphs grown by half its strokes' weight (#6824; #6617: Swatches panel menu: Show Recent Colors; scores unchanged; previously minor: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). > @@ -1750,7 +1750,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Envelopes of type map from the glyphs:** an envelope maps point type, area type, type in groups and layers and type used as a clipping path from the bounds of the glyph outlines, underlines and strikethroughs instead of the type's laid-out box (ascent to descent, or the area's frame), with inline graphics counted by their boxes; rotated type and type in perspective map from their outlines as drawn. Make with Mesh, Make with Top Object and Reset with Mesh without Maintain Shape use those bounds. Type that draws nothing keeps its laid-out box, and so does type inside any other object, such as a blend, a repeat or a symbol instance. This is VectorCraft's own rule; envelopes of type made in VectorCraft and in saved files redraw by it. Tests: `crates/engine/src/tests/envelope.rs`, and `tests/envelope_ink.rs` in `vectorcraft-eps`, `vectorcraft-render` and `vectorcraft-effects`. Scores and hours unchanged. -- **Top-object envelopes share each side by control polygon:** each piece of a top object's four sides takes a share of the content's width or height in proportion to the length of its control polygon, spread along the piece by its curve parameter (evenly along a segment without handles), where the sides used to share it out by length. Each corner is the nearest anchor within 47.5% of the bounding box from its box corner that has a handle 0.01 pt long or shorter, or whose tangent unit vectors differ by 0.01 or more in x or y and turn out of the outline, or else the outline's nearest point to that box corner. Envelopes opened from `.ai` files keep the corners the file saves, through moves, rotations and reflections. These are VectorCraft's own rules; existing top-object envelopes redraw by them, and gradient meshes made from a path keep sides measured by length. Tests: `a_top_object_*`, `a_reflected_top_object_keeps_the_corners_a_file_gives`, `a_split_curve_moves_the_top_object_share_to_its_new_anchor`, `a_rounded_rectangle_top_object_stays_close_to_the_identity` and `a_rectangle_top_object_is_the_identity` in `live::tests` (`vectorcraft-doc`), `envelope_top_object_shares_a_side_by_control_polygon_length` in `vectorcraft-engine`, `a_top_object_envelope_opens_live_with_its_outline` in `vectorcraft-eps`. Scores and hours unchanged. +- **Top-object envelopes share each side by control polygon:** each piece of a top object's four sides takes a share of the content's width or height in proportion to the length of its control polygon, spread along the piece by its curve parameter (evenly along a segment without handles), where the sides used to share it out by length. Each corner is the nearest anchor within 47.5% of the bounding box from its box corner that has a handle 0.01 pt long or shorter, or whose tangent unit vectors differ by 0.01 or more in x or y and turn out of the outline, or else the outline's nearest point to that box corner. Envelopes opened from `.ai` files keep the corners the file saves, through moves, rotations and reflections. 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 leaves how the side shares out the content as it was (corners found from the outline can still move, as one at the outline's nearest point snaps onto an anchor within 0.5% of its segment's parameter), and a top object whose `.ai` mesh saves each side's curve in pieces draws its contents along the whole curve's parameter, as the drawn art in users' files (used locally, never committed) does. A side only partly made of one curve keeps the polygon shares. These are VectorCraft's own rules; existing top-object envelopes redraw by them, and gradient meshes made from a path keep sides measured by length. Tests: `a_top_object_*`, `a_reflected_top_object_keeps_the_corners_a_file_gives`, `a_split_curve_keeps_the_top_object_patch`, `a_top_object_side_of_many_arcs_stays_on_them`, `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` and `envelope_top_object_keeps_its_look_when_anchors_are_added` in `vectorcraft-engine`, `a_top_object_envelope_opens_live_with_its_outline` and `a_top_object_whose_top_is_one_curve_split_in_thirds_opens_live` 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. @@ -2170,6 +2170,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-13 | minor | Envelopes: a top object's 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 of sharing the content out by the pieces' control polygons, so Add Anchor Points on such a side leaves how it shares out the content as it was, and a top object whose `.ai` mesh saves its sides' curves in pieces draws as the file's drawn art (`a_split_curve_keeps_the_top_object_patch`, `a_top_object_side_of_many_arcs_stays_on_them` and three more in `live::tests`, `envelope_top_object_keeps_its_look_when_anchors_are_added`, `a_top_object_whose_top_is_one_curve_split_in_thirds_opens_live`). Existing top-object envelopes with such sides redraw (#7097). Scores and estimates unchanged. | | 2026-11-12 | minor | Object › Rasterize with the Grayscale colour model makes a grayscale image (one gray channel, with alpha over a transparent background), so the Properties panel says Grayscale and a fill color colorizes it; Bitmap makes a 1-bit image (a TIFF), or a black-and-white gray image with alpha over a transparent background, since 1-bit gray with alpha is no format readers take. Both were RGB images of gray pixels (#6969). Scores and estimates unchanged. | | 2026-11-12 | minor | `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints (its strokes' miter tips, and strokes a Transform effect moves, included), after its glyphs grown by half its strokes' weight; `.ai` import evidence updated (#6824); scores and estimates unchanged. | | 2026-11-12 | minor | Explicit editing-data import keeps a single artboard beyond a capped 14,400 pt PDF page (#6865), with a warning; automatic mode stays unchanged. Scores and estimates unchanged. | From 6ed3935e59a2826c5f47231d33d3892009cf64fd Mon Sep 17 00:00:00 2001 From: jmverges Date: Fri, 13 Nov 2026 12:43:47 +0000 Subject: [PATCH 2/3] Envelopes: a top object's side split at a cusp still follows its curve Split where the curve stops (a cusp), both handles at the split lie on the anchor, so their lengths say nothing of how the curve's parameter divides between the two pieces, and the side fell back to the polygon shares. When both handles lie within ONE_CURVE of the anchor, the spans now come from how far each piece bends away from it (the second derivatives, in proportion to the squares of the spans). The existing check of every piece against the rebuilt curve is unchanged. Test: a_split_curve_keeps_the_top_object_patch_when_split_at_a_cusp. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/live.rs | 16 ++++++++++++++++ crates/doc/src/live/mesh.rs | 18 +++++++++++++++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index e667f2972d..6c0e880ee1 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -612,6 +612,22 @@ mod tests { } } + #[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_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 diff --git a/crates/doc/src/live/mesh.rs b/crates/doc/src/live/mesh.rs index 5e90a56462..dbebf9a439 100644 --- a/crates/doc/src/live/mesh.rs +++ b/crates/doc/src/live/mesh.rs @@ -362,7 +362,9 @@ fn control_polygon(c: &CubicBez) -> f64 { /// 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 +/// 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. 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 @@ -377,8 +379,18 @@ fn one_curve(pieces: &[CubicBez]) -> Option { 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) { + let ratio = if into.max(out) <= ONE_CURVE { + // Split where the curve stops (a cusp), both handles lie on the anchor and say nothing + // of the spans; how far each piece bends away from its anchor, in proportion to the + // squares of the spans, says it instead. + let bend_in = ((a.p3 - a.p2) - (a.p2 - a.p1)).hypot(); + let bend_out = ((b.p2 - b.p1) - (b.p1 - b.p0)).hypot(); + (bend_out / bend_in).sqrt() + } else { + out / into + }; + let next = spans.last().copied().unwrap_or(1.0) * ratio; + if !(next.is_finite() && next > 0.0) { return None; } spans.push(next); From e11155c692084aafed6d2339462907b19d50d0e7 Mon Sep 17 00:00:00 2001 From: jmverges Date: Fri, 13 Nov 2026 14:42:07 +0000 Subject: [PATCH 3/3] Envelopes: a top object's split goes by the longer of handles or bends The previous commit took the spans from how far each piece bends away from the split whenever both handles there were within ONE_CURVE of the anchor. A curve that barely moves at the split but doesn't bend there (a straight one, say) has short handles and no bend, so that gave 0/0 and the side fell back to the polygon shares. Now the spans come from whichever pair is the longer: the handles, or the bends (which go with the squares of the spans). The check of every piece against the rebuilt curve is unchanged. Test: a_split_curve_keeps_the_top_object_patch_where_it_barely_moves_and_runs_straight. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/live.rs | 17 +++++++++++++++++ crates/doc/src/live/mesh.rs | 19 ++++++++----------- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index 6c0e880ee1..cf7a4d2fa0 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -628,6 +628,23 @@ mod tests { } } + #[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 diff --git a/crates/doc/src/live/mesh.rs b/crates/doc/src/live/mesh.rs index dbebf9a439..b6d5bbc88d 100644 --- a/crates/doc/src/live/mesh.rs +++ b/crates/doc/src/live/mesh.rs @@ -364,7 +364,7 @@ fn control_polygon(c: &CubicBez) -> f64 { /// 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. Those proportions give each piece's span, and the curve follows from the first +/// 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 @@ -379,16 +379,13 @@ fn one_curve(pieces: &[CubicBez]) -> Option { 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 ratio = if into.max(out) <= ONE_CURVE { - // Split where the curve stops (a cusp), both handles lie on the anchor and say nothing - // of the spans; how far each piece bends away from its anchor, in proportion to the - // squares of the spans, says it instead. - let bend_in = ((a.p3 - a.p2) - (a.p2 - a.p1)).hypot(); - let bend_out = ((b.p2 - b.p1) - (b.p1 - b.p0)).hypot(); - (bend_out / bend_in).sqrt() - } else { - out / into - }; + // 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;