From 1682d59b6550a41b902e1fe3d15cf507b01b4a47 Mon Sep 17 00:00:00 2001 From: EricKuck Date: Wed, 14 Oct 2026 18:26:52 -0400 Subject: [PATCH 1/3] AI import: a gradient mesh patch's edges bend through the anchors listed between its corners Co-Authored-By: Claude Opus 5.5 --- crates/eps/src/import/ai/mesh.rs | 194 ++++++++++++++++++++++++++++++- 1 file changed, 191 insertions(+), 3 deletions(-) diff --git a/crates/eps/src/import/ai/mesh.rs b/crates/eps/src/import/ai/mesh.rs index 2bdabaab1..8e4fa9622 100644 --- a/crates/eps/src/import/ai/mesh.rs +++ b/crates/eps/src/import/ai/mesh.rs @@ -3,7 +3,12 @@ //! the origin round to the one beside it), ended by `/E` — and `/End X!`. //! //! A corner is `[colour… opacity x y` then the handle towards the next corner, a weight, the handle -//! towards the previous one and a weight `]`, in art space. Each patch becomes a 1×1 mesh. +//! towards the previous one and a weight `]`, in art space. A patch whose edges bend through more +//! anchors has them as `[x y` with a handle towards each neighbor and a weight `] /A` or `/S`, +//! after the corner their edge starts from (before the first corner for the edge back to it), in +//! the edge's direction (`/A`) or against it (`/S`). A patch with single cubic edges becomes a 1×1 +//! mesh; one with more anchors, the Coons patch of its edges, cut into cells where any edge has an +//! anchor. //! //! `X!`/`X#` aren't publicly documented (the AI7 specification predates meshes). @@ -20,6 +25,9 @@ const MAX_PATCHES: usize = 4096; pub(super) struct MeshReader { space: String, corners: Vec>, + /// The patch's anchors between its corners (`/A`, `/S`): the edge, whether listed against it, and + /// a point and its two handles. + anchors: Vec<(usize, bool, [f64; 6])>, pub patches: Vec, /// Something in it this doesn't read. pub unread: bool, @@ -42,9 +50,23 @@ impl MeshReader { } } } + "A" | "S" => { + if let Some(V::Arr(a)) = value { + let n: Vec = a.iter().filter_map(V::num).take(7).collect(); + match n.as_slice() { + &[x, y, ax, ay, bx, by, ..] if self.anchors.len() < 64 && [x, y, ax, ay, bx, by].iter().all(|v| v.is_finite()) => { + let p = |x, y| to_doc * Point::new(x, y); + let (at, a, b) = (p(x, y), p(ax, ay), p(bx, by)); + self.anchors.push(((self.corners.len() + 3) % 4, key == "S", [at.x, at.y, a.x, a.y, b.x, b.y])); + } + _ => self.unread = true, + } + } + } "E" => { let corners = std::mem::take(&mut self.corners); - match self.patch(&corners, to_doc) { + let anchors = std::mem::take(&mut self.anchors); + match self.patch(&corners, &anchors, to_doc) { Some(m) if self.patches.len() < MAX_PATCHES => self.patches.push(m), _ => self.unread = true, } @@ -63,7 +85,7 @@ impl MeshReader { }) } - fn patch(&self, corners: &[Vec], to_doc: Affine) -> Option { + fn patch(&self, corners: &[Vec], anchors: &[(usize, bool, [f64; 6])], to_doc: Affine) -> Option { let [n0, n1, n2, n3] = corners else { return None }; // (anchor, towards the next corner, towards the previous one, colour, opacity) let read = |n: &Vec| -> Option<(Point, Vec2, Vec2, Color, f32)> { @@ -77,6 +99,9 @@ impl MeshReader { Some((at, to_doc * Point::new(nx, ny) - at, to_doc * Point::new(px, py) - at, color, opacity.clamp(0.0, 1.0) as f32)) }; let (a, b, c, d) = (read(n0)?, read(n1)?, read(n2)?, read(n3)?); + if !anchors.is_empty() { + return Some(coons([a, b, c, d], anchors)); + } // Rows run from the first corner to the second, columns from the first to the fourth. let point = |(p, _, _, color, opacity): (Point, Vec2, Vec2, Color, f32), handles: [(usize, Vec2); 2]| { let mut h = [Vec2::ZERO; 4]; @@ -97,6 +122,143 @@ impl MeshReader { } } +type Corner = (Point, Vec2, Vec2, Color, f32); +type Cubic = [Point; 4]; + +/// A patch whose edges bend through more anchors: its edges (first corner → second → third → fourth +/// → first) as runs of cubics, each anchor on the edge it lies along, and the Coons patch they bound +/// as a mesh cut wherever an edge has an anchor, so each cell's edges are single cubics again. +fn coons(corners: [Corner; 4], anchors: &[(usize, bool, [f64; 6])]) -> GradientMesh { + let runs: Vec> = (0..4) + .map(|e| { + let (p, q) = (&corners[e], &corners[(e + 1) % 4]); + // Each anchor's first handle points to the one listed before it, so back along the edge + // for `/A`, forward for `/S`, which lists them against the edge. + let mut stops: Vec<(Point, Point, Point)> = anchors + .iter() + .filter(|(edge, ..)| *edge == e) + .map(|&(_, against, [x, y, ax, ay, bx, by])| { + let (at, h1, h2) = (Point::new(x, y), Point::new(ax, ay), Point::new(bx, by)); + if against { (h2, at, h1) } else { (h1, at, h2) } + }) + .collect(); + if anchors.iter().any(|&(edge, against, _)| edge == e && against) { + stops.reverse(); + } + let (mut from, mut out) = (p.0, p.0 + p.1); + let mut run = vec![]; + for (back, at, ahead) in stops { + run.push([from, out, back, at]); + (from, out) = (at, ahead); + } + run.push([from, out, q.0 + q.2, q.0]); + run + }) + .collect(); + // The patch is over u (first corner → fourth) and v (first → second). + let [left, bottom, right, top] = [ + Path { run: runs[0].clone(), reversed: false }, + Path { run: runs[1].clone(), reversed: false }, + Path { run: runs[2].clone(), reversed: true }, + Path { run: runs[3].clone(), reversed: true }, + ]; + let [ca, cb, cc, cd] = corners.map(|c| c.0.to_vec2()); + let surface = |u: f64, v: f64| -> Vec2 { + top.at(u) * (1.0 - v) + bottom.at(u) * v + left.at(v) * (1.0 - u) + right.at(v) * u + - (ca * ((1.0 - u) * (1.0 - v)) + cd * (u * (1.0 - v)) + cb * ((1.0 - u) * v) + cc * (u * v)) + }; + let du = |u: f64, v: f64, after: bool| -> Vec2 { + top.slope(u, after) * (1.0 - v) + bottom.slope(u, after) * v - left.at(v) + right.at(v) - (cd - ca) * (1.0 - v) - (cc - cb) * v + }; + let dv = |u: f64, v: f64, after: bool| -> Vec2 { + left.slope(v, after) * (1.0 - u) + right.slope(v, after) * u - top.at(u) + bottom.at(u) - (cb - ca) * (1.0 - u) - (cc - cd) * u + }; + let cuts = |a: &Path, b: &Path| { + let mut c: Vec = a.cuts().into_iter().chain(b.cuts()).collect(); + c.sort_by(f64::total_cmp); + c.dedup_by(|x, y| (*x - *y).abs() < 1e-9); + c + }; + let (us, vs) = (cuts(&top, &bottom), cuts(&left, &right)); + let (ra, rb, rc, rd) = (&corners[0], &corners[1], &corners[2], &corners[3]); + let mut points = Vec::with_capacity(us.len() * vs.len()); + for (r, &v) in vs.iter().enumerate() { + for (c, &u) in us.iter().enumerate() { + let mut h = [Vec2::ZERO; 4]; + if let Some(&next) = us.get(c + 1) { + h[H_RIGHT] = du(u, v, true) * ((next - u) / 3.0); + } + if let Some(&prev) = c.checked_sub(1).and_then(|i| us.get(i)) { + h[H_LEFT] = du(u, v, false) * (-(u - prev) / 3.0); + } + if let Some(&next) = vs.get(r + 1) { + h[H_DOWN] = dv(u, v, true) * ((next - v) / 3.0); + } + if let Some(&prev) = r.checked_sub(1).and_then(|i| vs.get(i)) { + h[H_UP] = dv(u, v, false) * (-(v - prev) / 3.0); + } + let (u32_, v32) = (u as f32, v as f32); + let color = mix(&mix(&ra.3, &rd.3, u32_), &mix(&rb.3, &rc.3, u32_), v32); + let opacity = (ra.4 * (1.0 - u32_) + rd.4 * u32_) * (1.0 - v32) + (rb.4 * (1.0 - u32_) + rc.4 * u32_) * v32; + points.push(MeshPoint { p: surface(u, v).to_point(), color, opacity, handles: h }); + } + } + GradientMesh { rows: (vs.len() - 1) as u32, cols: (us.len() - 1) as u32, points } +} + +/// An edge as a run of cubics, each taking an equal share of its parameter, read backwards if +/// `reversed`. +struct Path { + run: Vec, + reversed: bool, +} + +impl Path { + fn piece(&self, t: f64, after: bool) -> (usize, f64) { + let n = self.run.len(); + let t = if self.reversed { 1.0 - t } else { t }; + // At a joint, the piece the moving direction enters. + let towards_end = after != self.reversed; + let scaled = t * n as f64; + let mut i = if towards_end { scaled.floor() } else { scaled.ceil() - 1.0 }.clamp(0.0, (n - 1) as f64) as usize; + if i >= n { + i = n - 1; + } + (i, scaled - i as f64) + } + fn at(&self, t: f64) -> Vec2 { + let (i, s) = self.piece(t, true); + let [p0, p1, p2, p3] = self.run[i].map(|p| p.to_vec2()); + let r = 1.0 - s; + p0 * (r * r * r) + p1 * (3.0 * r * r * s) + p2 * (3.0 * r * s * s) + p3 * (s * s * s) + } + /// The derivative along the patch's parameter, from the piece `after` (towards larger `t`) or before. + fn slope(&self, t: f64, after: bool) -> Vec2 { + let (i, s) = self.piece(t, after); + let [p0, p1, p2, p3] = self.run[i].map(|p| p.to_vec2()); + let r = 1.0 - s; + let d = (p1 - p0) * (3.0 * r * r) + (p2 - p1) * (6.0 * r * s) + (p3 - p2) * (3.0 * s * s); + let scale = self.run.len() as f64; + if self.reversed { d * -scale } else { d * scale } + } + /// Where its pieces meet, with its ends, in the patch's parameter. + fn cuts(&self) -> Vec { + let n = self.run.len(); + (0..=n).map(|i| i as f64 / n as f64).collect() + } +} + +/// `a` towards `b` by `t`, in their own colour model where they share one. +fn mix(a: &Color, b: &Color, t: f32) -> Color { + let l = |x: f32, y: f32| x + (y - x) * t; + match (a, b) { + (Color::Rgb { r, g, b: bl }, Color::Rgb { r: r2, g: g2, b: b2 }) => Color::rgb(l(*r, *r2), l(*g, *g2), l(*bl, *b2)), + (Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => Color::cmyk(l(*c, *c2), l(*m, *m2), l(*y, *y2), l(*k, *k2)), + (Color::Gray { k }, Color::Gray { k: k2 }) => Color::gray(l(*k, *k2)), + _ => a.lerp(b, t), + } +} + #[cfg(test)] mod tests { use super::*; @@ -113,6 +275,32 @@ mod tests { reader.entry(&[V::Num(0.0), V::Name("E".into())], Affine::IDENTITY); } + /// An anchor on a patch's edge (`/A` after the first corner: on the edge from it to the second) + /// bends the edge through it: the patch is cut there into two cells whose shared corner is the + /// anchor, with the anchor's handles along the edge. + #[test] + fn an_edge_anchor_cuts_its_patch_there() { + let mut reader = MeshReader { space: "DeviceGray".into(), ..MeshReader::default() }; + let corner = |x: f64, y: f64, next: (f64, f64), prev: (f64, f64)| { + vec![V::Arr([0.0, 1.0, x, y, next.0, next.1, 1.0, prev.0, prev.1, 1.0].map(V::Num).to_vec()), V::Name("N".into())] + }; + reader.entry(&corner(0.0, 0.0, (0.0, 2.0), (2.0, 0.0)), Affine::IDENTITY); + reader.entry(&[V::Arr([-1.0, 5.0, -1.0, 4.0, -1.0, 6.0, 1.0].map(V::Num).to_vec()), V::Name("A".into())], Affine::IDENTITY); + reader.entry(&corner(0.0, 10.0, (2.0, 10.0), (0.0, 8.0)), Affine::IDENTITY); + reader.entry(&corner(10.0, 10.0, (10.0, 8.0), (8.0, 10.0)), Affine::IDENTITY); + reader.entry(&corner(10.0, 0.0, (8.0, 0.0), (10.0, 2.0)), Affine::IDENTITY); + reader.entry(&[V::Num(0.0), V::Name("E".into())], Affine::IDENTITY); + assert!(!reader.unread); + let mesh = &reader.patches[0]; + assert_eq!((mesh.rows, mesh.cols), (2, 1)); + let left = &mesh.points[2]; + assert!((left.p - Point::new(-1.0, 5.0)).hypot() < 1e-9, "{:?}", left.p); + assert!((left.handles[H_UP] - Vec2::new(0.0, -1.0)).hypot() < 1e-9 && (left.handles[H_DOWN] - Vec2::new(0.0, 1.0)).hypot() < 1e-9); + // The edge still starts and ends at its corners, with their handles. + assert!((mesh.points[0].handles[H_DOWN] - Vec2::new(0.0, 2.0)).hypot() < 1e-9); + assert!((mesh.points[4].p - Point::new(0.0, 10.0)).hypot() < 1e-9); + } + #[test] fn hostile_meshes_are_capped_and_checked() { let mut reader = MeshReader { space: "DeviceGray".into(), ..MeshReader::default() }; From 76888366e7c2f61e0e53cf89721fb30c5b870b6c Mon Sep 17 00:00:00 2001 From: EricKuck Date: Wed, 14 Oct 2026 20:22:22 -0400 Subject: [PATCH 2/3] AI import: a mesh patch's anchors run in the mesh's direction whatever their key Co-Authored-By: Claude Opus 5.5 --- crates/eps/src/import/ai/mesh.rs | 53 ++++++++++++++++++++++++-------- 1 file changed, 40 insertions(+), 13 deletions(-) diff --git a/crates/eps/src/import/ai/mesh.rs b/crates/eps/src/import/ai/mesh.rs index 8e4fa9622..eebf916a1 100644 --- a/crates/eps/src/import/ai/mesh.rs +++ b/crates/eps/src/import/ai/mesh.rs @@ -6,9 +6,9 @@ //! towards the previous one and a weight `]`, in art space. A patch whose edges bend through more //! anchors has them as `[x y` with a handle towards each neighbor and a weight `] /A` or `/S`, //! after the corner their edge starts from (before the first corner for the edge back to it), in -//! the edge's direction (`/A`) or against it (`/S`). A patch with single cubic edges becomes a 1×1 -//! mesh; one with more anchors, the Coons patch of its edges, cut into cells where any edge has an -//! anchor. +//! the mesh's own direction whichever the key: along the first two edges, against the last two. A +//! patch with single cubic edges becomes a 1×1 mesh; one with more anchors, the Coons patch of its +//! edges, cut into cells where any edge has an anchor. //! //! `X!`/`X#` aren't publicly documented (the AI7 specification predates meshes). @@ -25,9 +25,8 @@ const MAX_PATCHES: usize = 4096; pub(super) struct MeshReader { space: String, corners: Vec>, - /// The patch's anchors between its corners (`/A`, `/S`): the edge, whether listed against it, and - /// a point and its two handles. - anchors: Vec<(usize, bool, [f64; 6])>, + /// The patch's anchors between its corners (`/A`, `/S`): the edge, and a point and its two handles. + anchors: Vec<(usize, [f64; 6])>, pub patches: Vec, /// Something in it this doesn't read. pub unread: bool, @@ -57,7 +56,7 @@ impl MeshReader { &[x, y, ax, ay, bx, by, ..] if self.anchors.len() < 64 && [x, y, ax, ay, bx, by].iter().all(|v| v.is_finite()) => { let p = |x, y| to_doc * Point::new(x, y); let (at, a, b) = (p(x, y), p(ax, ay), p(bx, by)); - self.anchors.push(((self.corners.len() + 3) % 4, key == "S", [at.x, at.y, a.x, a.y, b.x, b.y])); + self.anchors.push(((self.corners.len() + 3) % 4, [at.x, at.y, a.x, a.y, b.x, b.y])); } _ => self.unread = true, } @@ -85,7 +84,7 @@ impl MeshReader { }) } - fn patch(&self, corners: &[Vec], anchors: &[(usize, bool, [f64; 6])], to_doc: Affine) -> Option { + fn patch(&self, corners: &[Vec], anchors: &[(usize, [f64; 6])], to_doc: Affine) -> Option { let [n0, n1, n2, n3] = corners else { return None }; // (anchor, towards the next corner, towards the previous one, colour, opacity) let read = |n: &Vec| -> Option<(Point, Vec2, Vec2, Color, f32)> { @@ -128,21 +127,21 @@ type Cubic = [Point; 4]; /// A patch whose edges bend through more anchors: its edges (first corner → second → third → fourth /// → first) as runs of cubics, each anchor on the edge it lies along, and the Coons patch they bound /// as a mesh cut wherever an edge has an anchor, so each cell's edges are single cubics again. -fn coons(corners: [Corner; 4], anchors: &[(usize, bool, [f64; 6])]) -> GradientMesh { +fn coons(corners: [Corner; 4], anchors: &[(usize, [f64; 6])]) -> GradientMesh { let runs: Vec> = (0..4) .map(|e| { let (p, q) = (&corners[e], &corners[(e + 1) % 4]); - // Each anchor's first handle points to the one listed before it, so back along the edge - // for `/A`, forward for `/S`, which lists them against the edge. + // Each anchor's first handle points to the one listed before it, back along the first two edges. + let against = e >= 2; let mut stops: Vec<(Point, Point, Point)> = anchors .iter() .filter(|(edge, ..)| *edge == e) - .map(|&(_, against, [x, y, ax, ay, bx, by])| { + .map(|&(_, [x, y, ax, ay, bx, by])| { let (at, h1, h2) = (Point::new(x, y), Point::new(ax, ay), Point::new(bx, by)); if against { (h2, at, h1) } else { (h1, at, h2) } }) .collect(); - if anchors.iter().any(|&(edge, against, _)| edge == e && against) { + if against { stops.reverse(); } let (mut from, mut out) = (p.0, p.0 + p.1); @@ -301,6 +300,34 @@ mod tests { assert!((mesh.points[4].p - Point::new(0.0, 10.0)).hypot() < 1e-9); } + /// Anchors are listed in the mesh's direction whatever their key: two `/S` anchors after the + /// first corner run along its edge, as the patch above a shared edge lists them. + #[test] + fn anchors_keyed_s_on_a_first_edge_run_along_it() { + let mut reader = MeshReader { space: "DeviceGray".into(), ..MeshReader::default() }; + let corner = |x: f64, y: f64, next: (f64, f64), prev: (f64, f64)| { + vec![V::Arr([0.0, 1.0, x, y, next.0, next.1, 1.0, prev.0, prev.1, 1.0].map(V::Num).to_vec()), V::Name("N".into())] + }; + let anchor = |y: f64| vec![V::Arr([-1.0, y, -1.0, y - 1.0, -1.0, y + 1.0, 1.0].map(V::Num).to_vec()), V::Name("S".into())]; + reader.entry(&corner(0.0, 0.0, (0.0, 1.0), (1.0, 0.0)), Affine::IDENTITY); + reader.entry(&anchor(3.0), Affine::IDENTITY); + reader.entry(&anchor(7.0), Affine::IDENTITY); + reader.entry(&corner(0.0, 10.0, (1.0, 10.0), (0.0, 9.0)), Affine::IDENTITY); + reader.entry(&corner(10.0, 10.0, (10.0, 9.0), (9.0, 10.0)), Affine::IDENTITY); + reader.entry(&corner(10.0, 0.0, (9.0, 0.0), (10.0, 1.0)), Affine::IDENTITY); + reader.entry(&[V::Num(0.0), V::Name("E".into())], Affine::IDENTITY); + let mesh = &reader.patches[0]; + assert_eq!((mesh.rows, mesh.cols), (3, 1)); + let (first, second) = (&mesh.points[2], &mesh.points[4]); + assert!( + (first.p - Point::new(-1.0, 3.0)).hypot() < 1e-9 && (second.p - Point::new(-1.0, 7.0)).hypot() < 1e-9, + "{:?} {:?}", + first.p, + second.p + ); + assert!((first.handles[H_DOWN] - Vec2::new(0.0, 1.0)).hypot() < 1e-9, "{:?}", first.handles); + } + #[test] fn hostile_meshes_are_capped_and_checked() { let mut reader = MeshReader { space: "DeviceGray".into(), ..MeshReader::default() }; From 8af04592321b8c9bff430966818530bfd509bf96 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 16 Oct 2026 02:37:35 +0700 Subject: [PATCH 3/3] AI import: a gradient mesh keeps at most 65,536 points, however its patches are cut --- crates/eps/src/import/ai/mesh.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/eps/src/import/ai/mesh.rs b/crates/eps/src/import/ai/mesh.rs index eebf916a1..136818a4e 100644 --- a/crates/eps/src/import/ai/mesh.rs +++ b/crates/eps/src/import/ai/mesh.rs @@ -20,6 +20,9 @@ use super::obj::V; /// The most patches read from one mesh. const MAX_PATCHES: usize = 4096; +/// The most points kept from one mesh: a patch with anchors on its edges is cut into cells, up to +/// 34 × 34 points, so the patch cap alone doesn't bound them. +const MAX_POINTS: usize = MAX_PATCHES * 16; #[derive(Debug, Default)] pub(super) struct MeshReader { @@ -28,6 +31,8 @@ pub(super) struct MeshReader { /// The patch's anchors between its corners (`/A`, `/S`): the edge, and a point and its two handles. anchors: Vec<(usize, [f64; 6])>, pub patches: Vec, + /// The points of [`Self::patches`]. + points: usize, /// Something in it this doesn't read. pub unread: bool, } @@ -66,7 +71,10 @@ impl MeshReader { let corners = std::mem::take(&mut self.corners); let anchors = std::mem::take(&mut self.anchors); match self.patch(&corners, &anchors, to_doc) { - Some(m) if self.patches.len() < MAX_PATCHES => self.patches.push(m), + Some(m) if self.patches.len() < MAX_PATCHES && self.points + m.points.len() <= MAX_POINTS => { + self.points += m.points.len(); + self.patches.push(m); + } _ => self.unread = true, } } @@ -335,6 +343,10 @@ mod tests { patch(&mut reader, &[0.5, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0]); } assert!(reader.unread && reader.patches.len() == MAX_PATCHES); + // Patches cut into cells count by their points. + let mut reader = MeshReader { space: "DeviceGray".into(), points: MAX_POINTS - 3, ..MeshReader::default() }; + patch(&mut reader, &[0.5, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0]); + assert!(reader.unread && reader.patches.is_empty()); // NaN passes `clamp`: a non-finite opacity or colour leaves the patch unread. for bad in [[0.5, f64::NAN], [f64::INFINITY, 1.0]] { let mut reader = MeshReader { space: "DeviceGray".into(), ..MeshReader::default() };