diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index 4a864a6e9..304c4e70f 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -643,6 +643,168 @@ mod tests { assert!(e.p1.distance(Point::new(30.0, 0.0)) < 1e-6 && e.p2.distance(Point::new(60.0, 0.0)) < 1e-6, "{e:?}"); } + /// The polylines of `bp` flattened to 0.01 pt. + fn flattened(bp: &BezPath) -> Vec> { + let mut out: Vec> = vec![]; + vectorcraft_geom::kurbo::flatten(bp.iter(), 0.01, |el| match el { + vectorcraft_geom::PathEl::MoveTo(p) => out.push(vec![p]), + vectorcraft_geom::PathEl::LineTo(p) => out.last_mut().into_iter().for_each(|v| v.push(p)), + vectorcraft_geom::PathEl::ClosePath => out.last_mut().into_iter().for_each(|v| v.push(v[0])), + _ => {} + }); + out + } + + /// The distance from `p` to the nearest of `lines`. + fn off_lines(lines: &[Vec], p: Point) -> f64 { + lines + .iter() + .flat_map(|v| v.windows(2)) + .map(|w| { + let d = w[1] - w[0]; + let t = if d.hypot2() > 0.0 { ((p - w[0]).dot(d) / d.hypot2()).clamp(0.0, 1.0) } else { 0.0 }; + p.distance(w[0] + d * t) + }) + .fold(f64::INFINITY, f64::min) + } + + /// How far apart the border of `m` and the outline of `path` are, in points: the larger of the + /// farthest a point of either lies from the other. + fn mesh_border_off_path(m: &GradientMesh, path: &PathData) -> f64 { + let (border, outline) = (flattened(&m.outline().to_bezpath()), flattened(&path.to_bezpath())); + let one = border.iter().flatten().map(|p| off_lines(&outline, *p)).fold(0.0, f64::max); + let other = outline.iter().flatten().map(|p| off_lines(&border, *p)).fold(0.0, f64::max); + one.max(other) + } + + /// #2752: a gradient mesh keeps the exact outline of the path it is made from, sharp corners + /// included, whether made by Create Gradient Mesh (1×1 and 4×4) or by a Mesh tool click at a + /// point inside. Before, the border of each mesh was one cubic per edge between four corners + /// near the bounding box's: up to 49 pt off a five-point star, 54 pt off a notched rectangle. + #[test] + fn a_mesh_keeps_the_outline_of_every_shape() { + const OFF: f64 = 0.1; + let poly = |pts: &[(f64, f64)]| PathData::single(SubPath::polyline(&pts.iter().map(|&(x, y)| Point::new(x, y)).collect::>(), true)); + let curves = |start: (f64, f64), cubics: &[[(f64, f64); 3]]| { + let mut bp = BezPath::new(); + bp.move_to(start); + for [a, b, c] in cubics { + bp.curve_to(*a, *b, *c); + } + bp.close_path(); + PathData::from_bezpath(&bp) + }; + let box_ = Rect::new(100.0, 100.0, 500.0, 400.0); + let heart = curves( + (300.0, 400.0), + &[ + [(200.0, 320.0), (100.0, 250.0), (100.0, 170.0)], + [(100.0, 100.0), (180.0, 70.0), (230.0, 90.0)], + [(270.0, 105.0), (290.0, 130.0), (300.0, 150.0)], + [(310.0, 130.0), (330.0, 105.0), (370.0, 90.0)], + [(420.0, 70.0), (500.0, 100.0), (500.0, 170.0)], + [(500.0, 250.0), (400.0, 320.0), (300.0, 400.0)], + ], + ); + let teardrop = curves( + (300.0, 100.0), + &[ + [(350.0, 200.0), (420.0, 250.0), (420.0, 320.0)], + [(420.0, 386.0), (366.0, 440.0), (300.0, 440.0)], + [(234.0, 440.0), (180.0, 386.0), (180.0, 320.0)], + [(180.0, 250.0), (250.0, 200.0), (300.0, 100.0)], + ], + ); + let shapes: [(&str, PathData, Point); 14] = [ + ("rectangle", shapes::rectangle(box_), Point::new(260.0, 230.0)), + ("rotated rectangle", shapes::rectangle(box_).transformed(Affine::rotate_about(0.5, box_.center())), Point::new(280.0, 240.0)), + ("triangle", poly(&[(300.0, 100.0), (500.0, 400.0), (100.0, 400.0)]), Point::new(300.0, 320.0)), + ("five-point star", shapes::star(Point::new(300.0, 300.0), 200.0, 80.0, 5, 0.0), Point::new(300.0, 300.0)), + ("twelve-point star", shapes::star(Point::new(300.0, 300.0), 200.0, 140.0, 12, 0.0), Point::new(300.0, 300.0)), + ( + "L-shape", + poly(&[(100.0, 100.0), (200.0, 100.0), (200.0, 300.0), (400.0, 300.0), (400.0, 400.0), (100.0, 400.0)]), + Point::new(150.0, 350.0), + ), + ("rounded rectangle", shapes::rounded_rectangle(box_, 40.0), Point::new(260.0, 230.0)), + ("heart", heart, Point::new(300.0, 250.0)), + ("teardrop", teardrop, Point::new(300.0, 330.0)), + ("ellipse", shapes::ellipse(box_), Point::new(200.0, 330.0)), + ("thin rectangle", shapes::rectangle(Rect::new(100.0, 100.0, 500.0, 110.0)), Point::new(260.0, 104.0)), + ( + "notched rectangle", + poly(&[(100.0, 100.0), (250.0, 100.0), (300.0, 200.0), (350.0, 100.0), (500.0, 100.0), (500.0, 400.0), (100.0, 400.0)]), + Point::new(260.0, 300.0), + ), + // A notch shorter than the steps along the outline an edge is checked at, and deeper + // than the tolerance. + ( + "rectangle with a tiny notch", + poly(&[(100.0, 100.0), (301.1, 100.0), (301.2, 100.3), (301.3, 100.0), (500.0, 100.0), (500.0, 400.0), (100.0, 400.0)]), + Point::new(260.0, 300.0), + ), + // An outline that crosses itself, whose two lobes' areas cancel. + ("bow tie", poly(&[(100.0, 100.0), (500.0, 400.0), (500.0, 100.0), (100.0, 400.0)]), Point::new(150.0, 250.0)), + ]; + // Every shape and way of making the mesh is checked, and all that stray are named. + let mut strays = vec![]; + for (name, path, click) in shapes { + for (rows, cols) in [(1, 1), (4, 4)] { + let m = GradientMesh::for_path(&path, rows, cols, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap(); + let off = mesh_border_off_path(&m, &path); + if off >= OFF { + strays.push(format!("{name}, {rows}×{cols}: border {off:.2} pt off the outline")); + } + } + let mut m = GradientMesh::for_path(&path, 1, 1, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap(); + assert!(m.add_lines_at(click, 0.0).is_some(), "{name}: the click at {click:?} is outside the mesh"); + let off = mesh_border_off_path(&m, &path); + if off >= OFF { + strays.push(format!("{name}, Mesh tool click: border {off:.2} pt off the outline")); + } + } + assert!(strays.is_empty(), "{strays:#?}"); + } + + /// The lines a mesh takes to keep a path's outline are as few as do it: a rectangle's and an + /// ellipse's meshes keep the rows and columns asked for, and a star's 1×1 mesh takes lines + /// through its points, at most one each way per anchor. Paths without an outline to follow + /// still give a mesh (an open path closes straight; a line there and back maps onto its box), + /// and a point that isn't finite gives none. + #[test] + fn a_mesh_takes_lines_only_where_the_outline_needs_them() { + let box_ = Rect::new(100.0, 100.0, 500.0, 400.0); + for path in [shapes::rectangle(box_), shapes::ellipse(box_)] { + for (rows, cols) in [(1, 1), (4, 4), (2, 5)] { + let m = GradientMesh::for_path(&path, rows, cols, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap(); + assert_eq!((m.rows, m.cols), (rows, cols)); + } + } + let star = shapes::star(Point::new(300.0, 300.0), 200.0, 80.0, 5, 0.0); + let m = GradientMesh::for_path(&star, 1, 1, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap(); + assert!(m.rows > 1 && m.cols > 1 && m.rows <= 10 && m.cols <= 10 && m.is_valid(), "{}×{}", m.rows, m.cols); + // Every anchor of the star is a point of the mesh. + for a in &star.subpaths[0].anchors { + assert!(m.points.iter().any(|q| q.p.distance(a.p) < 1e-6), "{:?} is not a mesh point", a.p); + } + // An open path closes with a straight segment. + let open = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 80.0)], false)); + let m = GradientMesh::for_path(&open, 2, 2, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap(); + let closed = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 80.0)], true)); + assert!(mesh_border_off_path(&m, &closed) < 0.1); + // A line there and back. + let line = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 50.0)], true)); + let m = GradientMesh::for_path(&line, 1, 1, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap(); + assert!(m.is_valid() && m.points.iter().all(|q| q.p.is_finite())); + let mut nan = shapes::rectangle(box_); + nan.subpaths[0].anchors[1].p.x = f64::NAN; + assert!(GradientMesh::for_path(&nan, 1, 1, Color::BLACK, MeshAppearance::Flat, 0.0).is_none()); + // A path of many anchors stays within the line limit and keeps its outline. + let many = shapes::star(Point::new(300.0, 300.0), 200.0, 190.0, 400, 0.0); + let m = GradientMesh::for_path(&many, 1, 1, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap(); + assert!(m.is_valid() && m.rows as usize <= mesh::MAX_BORDER_LINES && m.cols as usize <= mesh::MAX_BORDER_LINES, "{}×{}", m.rows, m.cols); + } + #[test] fn mesh_insert_and_remove_lines() { let mut m = diff --git a/crates/doc/src/live/mesh.rs b/crates/doc/src/live/mesh.rs index ca08a3a2a..c59374dee 100644 --- a/crates/doc/src/live/mesh.rs +++ b/crates/doc/src/live/mesh.rs @@ -470,6 +470,384 @@ fn fit_edge(side: &[Point], span: (f64, f64), ends: (Point, Point)) -> Option<(V fit } +/// The cubic from `p0` to `p3` leaving `p0` along `d0` and reaching `p3` against `d1` (unit +/// vectors; `d1` points from `p3` back into the curve) that comes closest (least squares) to the +/// points `qs`, which lie along the way in order; `None` when no such cubic has handles of positive +/// length. Each point is matched to the curve's nearest point, refined over a few rounds, as in +/// [`fit_edge`]. +fn fit_tangents(qs: &[Point], (p0, d0): (Point, Vec2), (p3, d1): (Point, Vec2)) -> Option { + const ROUNDS: usize = 6; + let n = qs.len() + 1; + let mut ts: Vec = (1..n).map(|i| i as f64 / n as f64).collect(); + let mut fit = None; + for _ in 0..ROUNDS { + let (mut c11, mut c12, mut c22, mut x1, mut x2) = (0.0, 0.0, 0.0, 0.0, 0.0); + for (q, &t) in qs.iter().zip(&ts) { + let s = 1.0 - t; + let (b0, b1, b2, b3) = (s * s * s, 3.0 * s * s * t, 3.0 * s * t * t, t * t * t); + let (a1, a2) = (d0 * b1, d1 * b2); + let r = q.to_vec2() - (b0 + b1) * p0.to_vec2() - (b2 + b3) * p3.to_vec2(); + c11 += a1.dot(a1); + c12 += a1.dot(a2); + c22 += a2.dot(a2); + x1 += r.dot(a1); + x2 += r.dot(a2); + } + let det = c11 * c22 - c12 * c12; + if det.abs() < 1e-12 { + break; + } + let (l1, l2) = ((c22 * x1 - c12 * x2) / det, (c11 * x2 - c12 * x1) / det); + if !(l1.is_finite() && l2.is_finite() && l1 > 0.0 && l2 > 0.0) { + break; + } + let c = CubicBez::new(p0, p0 + d0 * l1, p3 + d1 * l2, p3); + fit = Some(c); + let (a, b, k) = (c.p1 - c.p0, c.p2 - c.p1, c.p3 - c.p2); + for (q, t) in qs.iter().zip(ts.iter_mut()) { + let s = 1.0 - *t; + let v = 3.0 * (s * s * a + 2.0 * s * *t * b + *t * *t * k); + let acc = 6.0 * (s * (b - a) + *t * (k - b)); + let e = c.eval(*t) - *q; + let den = v.hypot2() + e.dot(acc); + if den.abs() > 1e-12 { + *t = (*t - e.dot(v) / den).clamp(0.0, 1.0); + } + } + } + fit +} + +/// How far (in points) a border edge of a mesh made from a path may stray from the path's outline +/// before the mesh takes a line through an anchor of the outline there; a thousandth of the outline's +/// size when that is less. +const BORDER_TOLERANCE: f64 = 0.1; + +/// The most lines each way a mesh made from a path takes to keep its border on the outline +/// ([`BORDER_TOLERANCE`]); past that, each border edge keeps its closest fit. +pub(super) const MAX_BORDER_LINES: usize = 256; + +/// The most rounds of new lines a mesh made from a path takes to keep its border on the outline. +const MAX_BORDER_ROUNDS: usize = 64; + +/// An anchor whose directions in and out differ by more than this (radians) is a corner: a border +/// edge that can't follow the outline takes a mesh line through its sharpest corner first. +const SHARP_TURN: f64 = 0.01; + +/// Steps of each segment's parameter at which [`Outline`] measures the segment's length. +const LENGTH_STEPS: usize = 32; + +/// Points of the outline each border edge of a mesh made from a path is fitted to +/// ([`Outline::fit`]). +const FIT_POINTS: usize = 32; + +/// Points of the outline, and of the fitted edge, at which [`Outline::fit`] measures how far the +/// two stray from each other. +const CHECK_POINTS: usize = 128; + +/// The outline a gradient mesh is made from: the largest subpath of a path, running clockwise on +/// screen, an open one closed by a straight segment, without segments of no length. A place on it +/// is a length from its first anchor, measured along a polyline through even steps of each +/// segment's parameter; the same length always gives the same point. +struct Outline { + segs: Vec, + /// Where each segment starts, and the whole length last. + starts: Vec, + /// For each segment, the length from its start at each of [`LENGTH_STEPS`] + 1 even steps of + /// its parameter. + table: Vec<[f64; LENGTH_STEPS + 1]>, + /// Lengths closer than this are the same place. + eps: f64, +} + +/// One side of the patch a mesh made from a path spans: the stretch of the outline from `start`, +/// `len` long, run backwards when `reversed` (the bottom and left sides run against the outline). +#[derive(Clone, Copy)] +struct Side { + start: f64, + len: f64, + reversed: bool, +} + +impl Side { + /// The place on the outline at `f` (0 to 1) along the side. + fn at(&self, f: f64) -> f64 { + self.start + if self.reversed { (1.0 - f) * self.len } else { f * self.len } + } +} + +/// The direction a cubic leaves its start in (`false`) or reaches its end in (`true`), from the +/// first handle or point that is not at the end; zero when all its points are together. +fn cubic_tangent(c: &CubicBez, end: bool) -> Vec2 { + let near = 1e-12 * (c.p0.to_vec2().hypot() + c.p3.to_vec2().hypot() + 1.0); + let options = if end { [c.p3 - c.p2, c.p3 - c.p1, c.p3 - c.p0] } else { [c.p1 - c.p0, c.p2 - c.p0, c.p3 - c.p0] }; + options.into_iter().find(|v| v.hypot() > near).unwrap_or(Vec2::ZERO) +} + +impl Outline { + /// The outline of `path`; `None` when it has no subpath of two anchors or more, a point that + /// isn't finite, or all its points on one line (a line there and back). + fn new(path: &PathData) -> Option { + let sp = path.subpaths.iter().filter(|s| s.anchors.len() >= 2).max_by(|a, b| a.area().abs().total_cmp(&b.area().abs()))?; + if !sp.anchors.iter().all(|a| a.p.is_finite() && a.h_in.is_finite() && a.h_out.is_finite()) { + return None; + } + let mut sp = sp.clone(); + if sp.signed_area() < 0.0 { + sp.reverse(); + } + // A straight segment's parameter runs evenly along it. + let line = |a: Point, b: Point| CubicBez::new(a, a.lerp(b, 1.0 / 3.0), a.lerp(b, 2.0 / 3.0), b); + let mut segs: Vec = + (0..sp.segment_count()).map(|i| if sp.segment_is_line(i) { line(sp.segment(i).p0, sp.segment(i).p3) } else { sp.segment(i) }).collect(); + if !sp.closed + && let (Some(a), Some(b)) = (sp.anchors.last(), sp.anchors.first()) + { + segs.push(line(a.p, b.p)); + } + segs.retain(|c| [c.p1, c.p2, c.p3].iter().any(|p| p.distance(c.p0) > 1e-9)); + if segs.len() < 2 { + return None; + } + let mut starts = Vec::with_capacity(segs.len() + 1); + let mut table = Vec::with_capacity(segs.len()); + let mut total = 0.0; + for c in &segs { + let mut lens = [0.0; LENGTH_STEPS + 1]; + let mut prev = c.p0; + for (k, len) in lens.iter_mut().enumerate().skip(1) { + let p = c.eval(k as f64 / LENGTH_STEPS as f64); + *len = prev.distance(p); + prev = p; + } + for k in 1..=LENGTH_STEPS { + lens[k] += lens[k - 1]; + } + starts.push(total); + total += lens[LENGTH_STEPS]; + table.push(lens); + } + starts.push(total); + if !(total.is_finite() && total > 0.0) { + return None; + } + // An outline whose points all lie on one line encloses nothing; one that crosses itself + // (a bow tie) may have areas that cancel, and is still followed. + let pts: Vec = segs.iter().flat_map(|c| [c.p0, c.p1, c.p2, c.p3]).collect(); + let first = pts.first().copied()?; + let far = pts.iter().copied().max_by(|a, b| a.distance_squared(first).total_cmp(&b.distance_squared(first)))?; + let dir = far - first; + let width = pts.iter().map(|p| ((*p - first).cross(dir) / dir.hypot()).abs()).fold(0.0, f64::max); + if !(dir.hypot() > 0.0 && width > 1e-9 * total) { + return None; + } + Some(Self { segs, starts, table, eps: total * 1e-9 }) + } + + fn total(&self) -> f64 { + self.starts.last().copied().unwrap_or_default() + } + + /// The bounding box of the outline. + fn bounds(&self) -> Option { + self.segs.iter().map(|c| c.bounding_box()).reduce(|a, b| a.union(b)) + } + + /// The parameter of segment `i` `local` along it (clamped to the segment). + fn seg_t(&self, i: usize, local: f64) -> f64 { + let Some(lens) = self.table.get(i) else { return 0.0 }; + let k = lens.partition_point(|x| *x <= local).clamp(1, LENGTH_STEPS) - 1; + let (l0, l1) = (lens[k], lens[k + 1]); + let f = if l1 > l0 { ((local - l0) / (l1 - l0)).clamp(0.0, 1.0) } else { 0.0 }; + (k as f64 + f) / LENGTH_STEPS as f64 + } + + /// The segment and its parameter at place `s` (wrapping around); a place at the very end of a + /// segment is the start of the next. + fn locate(&self, s: f64) -> (usize, f64) { + let n = self.segs.len(); + let s = s.rem_euclid(self.total()); + let mut i = self.starts.partition_point(|x| *x <= s).clamp(1, n) - 1; + if self.starts.get(i + 1).is_some_and(|end| end - s <= self.eps) { + i = (i + 1) % n; + return (i, 0.0); + } + (i, self.seg_t(i, s - self.starts[i])) + } + + fn point(&self, s: f64) -> Point { + let (i, t) = self.locate(s); + self.segs.get(i).map_or(Point::ZERO, |c| c.eval(t)) + } + + /// The anchors strictly inside the stretch from `a`, `len` long: each one's distance from `a` + /// and the segment it starts. + fn anchors_in(&self, a: f64, len: f64) -> Vec<(f64, usize)> { + let (total, n) = (self.total(), self.segs.len()); + let a = a.rem_euclid(total); + let (lo, hi) = (a + self.eps, a + len - self.eps); + let mut out = vec![]; + for lap in [0.0, total] { + let from = self.starts[..n].partition_point(|x| x + lap <= lo); + for (i, start) in self.starts[..n].iter().enumerate().skip(from) { + let p = start + lap; + if p >= hi { + break; + } + out.push((p - a, i)); + } + } + out + } + + /// The piece of the outline from `a`, `len` long, which no anchor lies inside. + fn piece(&self, a: f64, len: f64) -> CubicBez { + let (i, t0) = self.locate(a); + let Some(c) = self.segs.get(i) else { return CubicBez::new(Point::ZERO, Point::ZERO, Point::ZERO, Point::ZERO) }; + // At a segment's start (a place at the end of the one before counts as that), none of it is behind. + let behind = if t0 == 0.0 { 0.0 } else { a.rem_euclid(self.total()) - self.starts[i] }; + let t1 = self.seg_t(i, behind + len); + c.subsegment(t0..t1.max(t0)) + } + + /// The direction the outline leaves place `s` in (`into` false) or reaches it in (`into` true). + fn tangent(&self, s: f64, into: bool) -> Vec2 { + let n = self.segs.len(); + let (i, t) = self.locate(s); + let c = match (into, t <= 0.0) { + (true, true) => self.segs[(i + n - 1) % n], + (true, false) => self.segs[i].subsegment(0.0..t), + (false, _) => self.segs[i].subsegment(t..1.0), + }; + cubic_tangent(&c, into) + } + + /// How sharply the outline turns at the anchor starting segment `i` (radians, 0 to π). + fn turn(&self, i: usize) -> f64 { + let s = self.starts[i]; + let (din, dout) = (self.tangent(s, true), self.tangent(s, false)); + din.cross(dout).atan2(din.dot(dout)).abs() + } + + /// The place on the outline nearest `q`. + fn nearest(&self, q: Point) -> f64 { + use vectorcraft_geom::kurbo::ParamCurveNearest; + let mut best = (f64::INFINITY, 0.0); + for (i, c) in self.segs.iter().enumerate() { + let near = c.nearest(q, 1e-9); + if near.distance_sq < best.0 { + // The length at that parameter, from the table. + let lens = &self.table[i]; + let x = near.t * LENGTH_STEPS as f64; + let k = (x.floor() as usize).min(LENGTH_STEPS - 1); + let local = lens[k] + (lens[k + 1] - lens[k]) * (x - k as f64); + best = (near.distance_sq, self.starts[i] + local); + } + } + best.1 + } + + /// The cubic standing for the stretch from `a`, `len` long, and how far it strays from it: the + /// piece of the segment when no anchor lies inside, otherwise a cubic fitted to the outline with + /// its ends along the outline's own directions there. + fn fit(&self, a: f64, len: f64) -> (CubicBez, f64) { + let (p0, p3) = (self.point(a), self.point(a + len)); + if len <= self.eps { + return (CubicBez::new(p0, p0, p0, p0), 0.0); + } + let anchors = self.anchors_in(a, len); + if anchors.is_empty() { + return (self.piece(a, len), 0.0); + } + let qs: Vec = (1..FIT_POINTS).map(|i| self.point(a + len * i as f64 / FIT_POINTS as f64)).collect(); + let (d0, d1) = (self.tangent(a, false), -self.tangent(a + len, true)); + let unit = |v: Vec2| if v.hypot() > 0.0 { Some(v / v.hypot()) } else { None }; + let straight = CubicBez::new(p0, p0.lerp(p3, 1.0 / 3.0), p0.lerp(p3, 2.0 / 3.0), p3); + let c = match (unit(d0), unit(d1)) { + (Some(d0), Some(d1)) => fit_tangents(&qs, (p0, d0), (p3, d1)), + _ => None, + } + .unwrap_or(straight); + // Both ways: the outline's points from the cubic, and the cubic's points from the outline. + // The outline is checked at even steps and at every anchor inside, so a spike or a notch + // narrower than a step is not missed. + use vectorcraft_geom::kurbo::ParamCurveNearest; + let mut at: Vec = (0..=CHECK_POINTS).map(|i| len * i as f64 / CHECK_POINTS as f64).chain(anchors.iter().map(|(o, _)| *o)).collect(); + at.sort_by(f64::total_cmp); + let line: Vec = at.iter().map(|o| self.point(a + o)).collect(); + let off_outline = line.iter().map(|q| c.nearest(*q, 1e-9).distance_sq.sqrt()).fold(0.0, f64::max); + let off_cubic = (0..=CHECK_POINTS) + .map(|i| { + let p = c.eval(i as f64 / CHECK_POINTS as f64); + line.windows(2) + .map(|w| { + let (u, v) = (w[0], w[1]); + let d = v - u; + let t = if d.hypot2() > 0.0 { ((p - u).dot(d) / d.hypot2()).clamp(0.0, 1.0) } else { 0.0 }; + p.distance(u + d * t) + }) + .fold(f64::INFINITY, f64::min) + }) + .fold(0.0, f64::max); + (c, off_outline.max(off_cubic)) + } + + /// The border edge of `side` from `f0` to `f1` (0 to 1 along it, rising), run the way the side + /// runs, and when it strays from the outline by more than `tol`, where along the side (0 to 1) + /// a mesh line through an anchor would help: the sharpest corner inside it, or the anchor + /// nearest its middle when none is sharp. + fn edge(&self, side: &Side, f0: f64, f1: f64, tol: f64) -> (CubicBez, Option) { + let len = (f1 - f0) * side.len; + let a = side.at(if side.reversed { f1 } else { f0 }); + let (c, off) = self.fit(a, len); + let c = if side.reversed { CubicBez::new(c.p3, c.p2, c.p1, c.p0) } else { c }; + if off <= tol || side.len <= 0.0 { + return (c, None); + } + let anchors = self.anchors_in(a, len); + let sharpest = anchors.iter().map(|&(o, i)| (self.turn(i), o)).max_by(|x, y| x.0.total_cmp(&y.0)); + let at = match sharpest { + Some((turn, o)) if turn > SHARP_TURN => Some(o), + _ => anchors.iter().map(|&(o, _)| o).min_by(|x, y| (x - len / 2.0).abs().total_cmp(&(y - len / 2.0).abs())), + }; + let split = at.map(|o| if side.reversed { f1 - o / side.len } else { f0 + o / side.len }); + (c, split.filter(|f| *f > f0 && *f < f1)) + } + + /// The cuts (0 to 1) of the lines across sides `one` and `other`: `even`, and as many more as + /// keep every border edge along them within `tol` of the outline (see [`Self::edge`]), at most + /// [`MAX_BORDER_LINES`] in all. + fn cuts(&self, one: &Side, other: &Side, even: Vec, tol: f64) -> Vec { + let mut cuts = even; + for _ in 0..MAX_BORDER_ROUNDS { + let mut add: Vec = + [one, other].iter().flat_map(|s| cuts.windows(2).filter_map(|w| self.edge(s, w[0], w[1], tol).1).collect::>()).collect(); + add.retain(|f| cuts.iter().all(|c| (c - f).abs() > 1e-9)); + add.truncate((MAX_BORDER_LINES + 1).saturating_sub(cuts.len())); + if add.is_empty() { + break; + } + cuts.extend(add); + cuts.sort_by(f64::total_cmp); + cuts.dedup_by(|x, y| (*x - *y).abs() <= 1e-9); + } + cuts + } + + /// The points along `side` at `knots` (rising, 0 to 1). + fn side_points(&self, side: &Side, knots: &[f64]) -> Vec { + knots.iter().map(|f| self.point(side.at(*f))).collect() + } +} + +/// The sorted union of `cuts` and `SIDE` even steps. +fn side_knots(cuts: &[f64]) -> Vec { + let mut k: Vec = (0..SIDE).map(|i| i as f64 / (SIDE - 1) as f64).chain(cuts.iter().copied()).collect(); + k.sort_by(f64::total_cmp); + k.dedup_by(|x, y| (*x - *y).abs() <= 1e-9); + k +} + /// The handles of one cubic standing for two edges that meet at point `mid.0`, whose handles /// towards them are `mid.1` (towards `a`) and `mid.2` (towards `b`), as offsets from their outer /// ends: each outer handle grown back by the share of the curve its edge had, which undoes the @@ -599,19 +977,104 @@ impl GradientMesh { /// Create Gradient Mesh for a path: the outline split at its corners, coloured per `appearance`. pub fn for_path(path: &PathData, rows: u32, cols: u32, base: Color, appearance: MeshAppearance, highlight: f64) -> Option { - let mut m = Self::for_path_with(path, rows, cols, &|_| (base, 1.0))?; - m.highlight(appearance, highlight); + Self::for_path_shaded(path, rows, cols, &|_| (base, 1.0), appearance, highlight) + } + + /// As [`Self::for_path_with`], then lightened per `appearance` ([`Self::highlight`]), measured + /// by where each line crosses the patch. + pub fn for_path_shaded( + path: &PathData, + rows: u32, + cols: u32, + color_at: &dyn Fn(Point) -> (Color, f32), + appearance: MeshAppearance, + highlight: f64, + ) -> Option { + let (mut m, us, vs) = Self::build_for_path(path, rows, cols, color_at)?; + m.highlight_at(appearance, highlight, &us, &vs); Some(m) } /// Create Gradient Mesh for a path: the outline split at its corners, each point taking the /// colour and opacity `color_at` gives at its position (a gradient fill's, say). pub fn for_path_with(path: &PathData, rows: u32, cols: u32, color_at: &dyn Fn(Point) -> (Color, f32)) -> Option { - let coons = Coons::from_path(path)?; + Self::build_for_path(path, rows, cols, color_at).map(|(m, ..)| m) + } + + /// The mesh of [`Self::for_path_with`] and where its column and row lines cross the patch (0 + /// to 1). Its border lies on the outline of the path's largest subpath (#2752): the patch's + /// corners are the outline's places nearest the corners of its bounding box (when those don't + /// come in order round the outline, the top left one and the places a quarter, a half and three + /// quarters of the way round from it), its points on the + /// border sit on the outline, and each border edge is the piece of the outline between its + /// points, a single cubic. Where the outline bends through an anchor between two of the `rows` + /// × `cols` lines and no single cubic follows it within [`BORDER_TOLERANCE`], the mesh takes a + /// line through that anchor (its sharpest corner first), as many as that needs up to + /// [`MAX_BORDER_LINES`] each way. `None` when the path has no outline or a point that isn't + /// finite; an outline whose points all lie on one line gives the mesh of its bounding box. + fn build_for_path(path: &PathData, rows: u32, cols: u32, color_at: &dyn Fn(Point) -> (Color, f32)) -> Option<(Self, Vec, Vec)> { + if !path.subpaths.iter().flat_map(|s| &s.anchors).all(|a| a.p.is_finite() && a.h_in.is_finite() && a.h_out.is_finite()) { + return None; + } let (us, vs) = (even_steps(cols), even_steps(rows)); + let Some((o, sides)) = Outline::new(path).and_then(|o| { + let r = o.bounds()?; + let [tl, mut tr, mut br, mut bl] = [(r.x0, r.y0), (r.x1, r.y0), (r.x1, r.y1), (r.x0, r.y1)].map(|(x, y)| o.nearest(Point::new(x, y))); + let span = |a: f64, b: f64| (b - a).rem_euclid(o.total()); + // Corners met out of order around the outline would make sides that overlap: the + // other three then go a quarter of the way round from the top left each. + let around = span(tl, tr) + span(tr, br) + span(br, bl) + span(bl, tl); + if (around - o.total()).abs() > 1e-6 * o.total() { + let quarter = o.total() / 4.0; + (tr, br, bl) = (tl + quarter, tl + 2.0 * quarter, tl + 3.0 * quarter); + } + let sides = [ + Side { start: tl, len: span(tl, tr), reversed: false }, + Side { start: tr, len: span(tr, br), reversed: false }, + Side { start: br, len: span(br, bl), reversed: true }, + Side { start: bl, len: span(bl, tl), reversed: true }, + ]; + Some((o, sides)) + }) else { + // No outline to follow: the patch of the flattened outline (its bounding box when it + // encloses nothing), with each border edge fitted. + let coons = Coons::from_path(path)?; + let mut m = Self::from_grid(&us, &vs, &|u, v| coons.eval(u, v), &|_, _, p| color_at(p)); + m.fit_border(&coons, &us, &vs); + return Some((m, us, vs)); + }; + let [top, right, bottom, left] = sides; + let size = o.bounds().map_or(0.0, |r| r.width() + r.height()); + let tol = BORDER_TOLERANCE.min(size * 1e-3).max(o.eps); + let us = o.cuts(&top, &bottom, us, tol); + let vs = o.cuts(&left, &right, vs, tol); + let (ku, kv) = (side_knots(&us), side_knots(&vs)); + let coons = Coons { + top: o.side_points(&top, &ku), + right: o.side_points(&right, &kv), + bottom: o.side_points(&bottom, &ku), + left: o.side_points(&left, &kv), + knots: Some(Box::new([ku.clone(), kv.clone(), ku, kv])), + }; let mut m = Self::from_grid(&us, &vs, &|u, v| coons.eval(u, v), &|_, _, p| color_at(p)); - m.fit_border(&coons, &us, &vs); - Some(m) + let (rows, cols) = (m.rows as usize, m.cols as usize); + let edges = [(0, &top), (rows, &bottom)] + .map(|(r, side)| (r, side, true)) + .into_iter() + .chain([(0, &left), (cols, &right)].map(|(c, side)| (c, side, false))); + for (line, side, along_u) in edges { + let steps = if along_u { &us } else { &vs }; + for (k, w) in steps.windows(2).enumerate() { + let (a, b, out, back) = + if along_u { (m.idx(line, k), m.idx(line, k + 1), H_RIGHT, H_LEFT) } else { (m.idx(k, line), m.idx(k + 1, line), H_DOWN, H_UP) }; + let (c, _) = o.edge(side, w[0], w[1], tol); + m.points[a].p = c.p0; + m.points[b].p = c.p3; + m.points[a].handles[out] = c.p1 - c.p0; + m.points[b].handles[back] = c.p2 - c.p3; + } + } + Some((m, us, vs)) } /// Give each edge on the mesh's border the handles that make it follow the outline it stands @@ -657,11 +1120,21 @@ impl GradientMesh { /// the edges, by at most `highlight` %. Each point lightens towards white in its own colour /// model (paper white, no ink, for CMYK), so a CMYK or grey mesh keeps its inks (#3232). pub fn highlight(&mut self, appearance: MeshAppearance, highlight: f64) { + let even = |n: u32| (0..=n).map(|i| f64::from(i) / f64::from(n)).collect::>(); + let (us, vs) = (even(self.cols), even(self.rows)); + self.highlight_at(appearance, highlight, &us, &vs); + } + + /// As [`Self::highlight`], with the column lines at `us` and the row lines at `vs` (0 to 1 + /// across the patch); a mesh whose lines don't match them is left as it is. + fn highlight_at(&mut self, appearance: MeshAppearance, highlight: f64, us: &[f64], vs: &[f64]) { let h = (highlight / 100.0).clamp(0.0, 1.0) as f32; let (rows, cols) = (self.rows as usize, self.cols as usize); - for r in 0..=rows { - for c in 0..=cols { - let (u, v) = (c as f64 / cols as f64, r as f64 / rows as f64); + if !self.is_valid() || us.len() != cols + 1 || vs.len() != rows + 1 { + return; + } + for (r, &v) in vs.iter().enumerate() { + for (c, &u) in us.iter().enumerate() { let d = ((u - 0.5).abs().max((v - 0.5).abs()) * 2.0) as f32; // 0 centre … 1 edge let w = match appearance { MeshAppearance::Flat => 0.0, diff --git a/crates/engine/src/cmd/live/mesh.rs b/crates/engine/src/cmd/live/mesh.rs index 87a7af357..70c2d9f34 100644 --- a/crates/engine/src/cmd/live/mesh.rs +++ b/crates/engine/src/cmd/live/mesh.rs @@ -49,10 +49,7 @@ pub(super) fn mesh_create(s: &mut Session, p: &Value) -> Result { match n.appearance.fill_paint() { // The points take the colours the gradient paints there. Paint::Gradient(g) => { - path.bounds().and_then(|b| GradientMesh::for_path_with(&path, rows, cols, &*gradient_sampler(&g, b, model))).map(|mut m| { - m.highlight(app, highlight); - m - }) + path.bounds().and_then(|b| GradientMesh::for_path_shaded(&path, rows, cols, &*gradient_sampler(&g, b, model), app, highlight)) } p => GradientMesh::for_path(&path, rows, cols, p.color().unwrap_or(Color::WHITE), app, highlight), } diff --git a/crates/engine/src/tests/live.rs b/crates/engine/src/tests/live.rs index 08fc120e0..9fc790efa 100644 --- a/crates/engine/src/tests/live.rs +++ b/crates/engine/src/tests/live.rs @@ -945,6 +945,34 @@ fn mesh_point_colour_move_and_undo() { assert!(s.execute("object.mesh.setPointColor", &json!({"id": a.0, "index": 99, "color": "#000000"})).is_err()); } +/// #2752: a Mesh tool click on a star, solid or gradient filled, gives a mesh with every point of +/// the star on its border, in one undo step that gives the star back. +#[test] +fn a_mesh_tool_click_keeps_a_stars_points() { + for gradient in [false, true] { + let mut s = session(); + let a = id_of(&s.execute("shape.star", &json!({"cx": 300, "cy": 300, "radius1": 200, "radius2": 80, "points": 5})).unwrap()); + if gradient { + s.execute("paint.setFill", &json!({"ids": [a.0], "gradient": {"kind": "linear"}})).unwrap(); + } else { + fill(&mut s, a, "#0000ff"); + } + let star = node(&s, a).path_data().unwrap().clone(); + sel(&mut s, &[a]); + let undos = undo_len(&s); + s.execute("object.mesh.create", &json!({"at": [300, 300]})).unwrap(); + assert_eq!(undo_len(&s), undos + 1); + let m = mesh_of(&s, a); + let border = m.outline().to_bezpath(); + for anchor in &star.subpaths[0].anchors { + assert!(m.points.iter().any(|q| q.p.distance(anchor.p) < 1e-6), "{:?} is not a mesh point", anchor.p); + assert!(vectorcraft_geom::Shape::bounding_box(&border).inflate(1e-6, 1e-6).contains(anchor.p)); + } + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, a).path_data(), Some(&star)); + } +} + /// A mesh point's `color` with an alpha part (`#rrggbbaa` or `#rgba`) is refused and changes /// nothing, also when `opacity` is given and when Add Mesh Line takes it; `#rrggbb` still sets it. #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3b9bc91bd..c86a292e9 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1191,6 +1191,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Image Trace, simpler contour walk:** `contour::trace_mask` works out each turn from the pixels during the walk, with no precomputed direction table and no assertion; identical loops to the previous walk on every 4 × 4 mask (see [quality notes](image-trace-quality.md#a-simpler-contour-walk)). - **Image Trace, measured:** `crates/trace/tests/quality` traces drawings made from known vector art with every preset and [`image-trace-quality.md`](image-trace-quality.md) records the numbers against the baseline. Black and White, Grayscale and Color place each edge by the blend of the pixels across it (to a fraction of a pixel on anti-aliased and blurred images; hard-edged images are read as staircases) and fit outlines as Flat Logo does. *Measured* on each preset's own inputs, all sizes and variants: Default's outlines went from 0.31 px off on average to 0.09 (largest 1.51 to 1.02), Black and White Logo's from 0.27 to 0.06 with 0.3 kinks per trace where it had 15, and the gaps between abutting paths from 64 px to 22. On clean curved shapes Black and White Logo is 0.02 px off (the open tracer measured beside it: 0.20). The palette is made of the picture's own colours (blends along edges take none of it, colours that noise made of one colour merge, a blend pixel goes to one of the two colours it lies between; #1504): 16 Colors on flat art, all variants, went from 70 paths to 4.2 and from 5.1 px off to 0.13, with no path left in a colour the art doesn't have (5.9 before). Photos are smoothed within their tones before they are quantised and stacked layers go by lightness (#1516): High Fidelity Photo went from 3,653 paths to 121 with its colour error down from 2.30 to 1.75, and a 16 megapixel photo-like image is traced in 3.5 s where 4 megapixels were refused. Since then, by the same harness: outlines are cut at their junctions and each shared piece is fitted once (#1535: gaps and overlaps between abutting paths 22 to 50 px of area down to under 2); layers are traced within the box of their pixels and a few at a time (#1546: a 16 megapixel photo 3.9 GB to 0.9 GB); black and white are read against the image's own black and the paper near each edge (#1563, for thin lines in blurred and scanned art); Ignore Color and the Auto Color and Outline presets (#1574); Snap Curves To Lines levels lines within 2.5 degrees of level or upright (#1593, the angle our choice); Create: Strokes puts centre lines in the middle of their ink and makes them as wide as it (#1595: Outline 0.295 to 0.052 px from the true centre line, 18.4 to 2.7 anchors a stroke); sharp corners whose pixel is mostly outside the shape stay corners (#1609: Default 69% to 76% kept). Three options, each off unless asked for as the legacy presets have them: Shapes (#1609: 57 to 62% of plain circles, ellipses, rectangles and regular polygons come out as live shapes, rounded rectangles rarely), Auto Grouping (#1618: paths grouped with what lies inside them, the same picture) and Gradients (#1627: linear gradients traced as one gradient-filled path; on gradient inputs 6 Colors' colour error 2.12 to 0.62 with 6.4 to 3.4 paths, none found in flat art). Snap Curves To Lines moves no end of a line by more than 1.6 px, so long lines a degree or two off stay as the image has them (#1647: Technical Drawing's largest distance 4.21 px to 1.46). Flat Logo takes the tones a scan's uneven paper makes of one colour for one colour (#1657: on scans 470 paths and 21 px off to 8.5 paths and 0.09 px). Shapes finds rounded rectangles and shapes in aliased, blurred and scanned art (#1845: 57 to 62% of the plain shapes up to 98 to 100%, no false shape but one line 2 px wide). Stroked lines get square caps where their ink is cut square, and a wide end that turned towards a corner goes back on its line (#1846: Outline's largest distance 2.94 px to 0.88, 14 anchors to 10). Each of the 14 documented presets has an enhanced version with Auto Grouping and, in all but High Fidelity Photo, Shapes (#1847), measured beside its preset: equal or better within 0.005 px and 1% of edge ΔE on every preset's own inputs (Default 0.091 to 0.083 px); Auto Grouping keeps traced order in its groups, which removed a light seam along the counters of letters. Gradients grows a gradient over the pixels noise and soft rims took off it (#1848: blurred and noisy linear gradients 3 to 11 paths beside the gradient down to 0.5 with 6 Colors, 24 to 56 down to 0.5 to 1.5 with High Fidelity Photo). Silhouettes is 0.52 px off because its threshold is 200 (0.11 px at 128). Still weak, by the same numbers: rounded rectangles and other line-and-arc outlines take 12 to 16 anchors (the fitter does not split lines from arcs), blurred and scanned polygons lose corners, hairlines under half a pixel are lost to the threshold, aliased art is about 0.25 px off, a scan's uneven paper splits into tones when more colours are asked for than the art has, stroke junctions stay at their skeleton pixels, a three-stop gradient in a small or scanned disc is not found, radial gradients stay bands (a draft, #1849, traces them; the documented option is for linear ones, so it waits for a decision), near-regular polygons a few dozen pixels across are made regular with Shapes (up to 0.5 px off), and aliased stroked lines are 0.8 px off at worst. - **Gradient mesh from an image:** Object › Create Gradient Mesh (`object.mesh.create`) on an embedded image makes a mesh of 1 to 50 rows and columns over the picture's rectangle, placed with the image's transform; each point takes the alpha-weighted average colour and the opacity of the area around it, and Appearance and Highlight apply. A linked image is refused with a message to embed it (#1676). Scores and hours unchanged. +- **Gradient mesh keeps the object's outline (#2752):** Create Gradient Mesh, a Mesh tool click and Expand of a gradient to a mesh lay the mesh's border on the path's outline: each border edge is the piece of the outline between its two points, and where the outline bends through an anchor (a star's points, an L-shape's inner corner, a rounded rectangle's arcs) no single curve follows, the mesh takes a line through that anchor, up to 256 each way. Holes and other subpaths of a compound path are still left out. Public reference: [About mesh objects and Create mesh objects, CS3 user guide, pages 184 to 185](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf) (mesh lines carry anchor points as well as mesh points). Tests: `a_mesh_keeps_the_outline_of_every_shape` and `a_mesh_takes_lines_only_where_the_outline_needs_them` in `crates/doc/src/live.rs`, `a_mesh_tool_click_keeps_a_stars_points` in `crates/engine/src/tests/live.rs`. Scores and hours unchanged. - **Several mesh points at once (#4207, #4208, #4170):** the Mesh tool and Direct Selection pick several points of a gradient mesh or mesh envelope: Shift-click adds a point or takes a picked one away, Direct Selection's marquee picks the points inside it (Shift toggles them) and selects their meshes, and its click on a mesh line picks the line's two ends and one inside a patch the four points around it. The picked points show filled; dragging one moves them all as one undo step and the arrow keys nudge them (`object.mesh.movePoints {id, points, dx, dy}` or `{items, dx, dy}`), and a click on one of several picked points picks it alone. A solid fill colour (`paint.setFill`: Color panel, Swatches, Eyedropper) recolours every picked point, and the Transparency panel's Opacity fades them instead of the object, the panel showing their opacity (blank when it differs); `object.mesh.setPointColor` takes `points` or `items`. Public reference: [Edit mesh objects](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-meshes/edit-mesh-objects.html) (Shift selects multiple mesh points; a fill colour colours the selected point), [Macworld, gradient mesh](https://www.macworld.com/article/202693/gmesh2.html) (Direct Selection: a mesh line selects the points at both ends, a patch the points around it, Shift-click adds or removes points), [Peachpit](https://www.peachpit.com/articles/article.aspx?p=1638270) (each mesh point can have its own colour or transparency). Tests: `shift_clicks_pick_mesh_points_that_drag_and_nudge_together` and `direct_selection_picks_mesh_points_by_marquee_line_and_patch` in `crates/tools/src/meshedit.rs`, `picked_mesh_points_take_the_fill_opacity_and_drag_together` in `crates/engine/src/tests/editcolors.rs`, `mesh_points_move_and_colour_together` in `crates/engine/src/tests/live.rs`. Scores and hours unchanged. - **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. Line art whose only solid area is white paper (strokes a pixel or two wide) takes its colours from the strokes' cores, with the lighter shades along a stroke's anti-aliased edge folded into its ink; a page whose ink (a channel at least 48 below white) covers under 1% of it traces to nothing, as blank paper does. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original. - **Image Trace, Flat Logo named colors only:** with `logoColors` named, `"logoWhite": false` traces those colors only, so white areas take the nearest of them and no white is added; a named white is kept, and enclosed transparent areas stay holes; code-drawn tests in `crates/trace` and `crates/engine`.