From 5d8b5ddc6c1324abc62d30a5daf264cc7ca4685f Mon Sep 17 00:00:00 2001 From: andeecollard Date: Thu, 15 Oct 2026 18:06:48 +0000 Subject: [PATCH 1/4] Image Trace: keep shared corners identical at nearby junctions When a long horizontal or vertical run crosses a three-colour junction one or two pixels before a corner, the corner correction for that run borrowed a neighbouring crack's shift that the region on the other side of the junction never applied, leaving hairline gaps between abutting colours. The correction now applies only when all three cracks it uses lie in the same shared piece. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018tBcdwNhptqtmX32JCGFzB --- crates/trace/src/subpixel.rs | 72 +++++++++++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 2 deletions(-) diff --git a/crates/trace/src/subpixel.rs b/crates/trace/src/subpixel.rs index 8420a2966..534fc1060 100644 --- a/crates/trace/src/subpixel.rs +++ b/crates/trace/src/subpixel.rs @@ -185,10 +185,13 @@ pub(crate) fn outline( continue; } let (before, edge) = (&edges[b], &edges[e]); - if before.len >= 3 { + // The three cracks must belong to the same shared piece. A junction + // can split a long raw edge near this corner; the opposite region + // then sees a shorter edge and would not make this correction. + if before.len >= 3 && !(1..=2).any(|k| junction(before.first + before.len - k)) { shifts[before.first + before.len - 1] = shifts[before.first + before.len - 2]; } - if edge.len >= 3 { + if edge.len >= 3 && !(1..=2).any(|k| junction(edge.first + k)) { shifts[edge.first] = shifts[edge.first + 1]; } } @@ -362,6 +365,71 @@ mod tests { assert!(pts.iter().all(|p| p.x.is_finite() && p.y.is_finite())); } + fn check_junction_near_corner(distance: usize) { + // The bottom region's long top run crosses a junction near its corner. + // Its neighbour sees only the short shared part after the junction. + let (w, h) = (12, 12); + for turns in 0..4 { + for palette in [[0, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]] { + let mut labels = vec![0u32; w * h]; + for y in 0..h { + for x in 0..w { + let label = if y < 4 && x < 5 - distance { + 0 + } else if y >= 4 && x < 5 { + 2 + } else { + 1 + }; + let (mut xx, mut yy) = (x, y); + for _ in 0..turns { + (xx, yy) = (h - 1 - yy, xx); + } + labels[yy * w + xx] = palette[label]; + } + } + let across = |c: Crack| { + let (a, b) = (c.at(0).unwrap(), c.at(1).unwrap()); + let wave = 0.4 * ((a + b) as f64).sin(); + (if labels[a] < labels[b] { wave } else { -wave }, labels[b]) + }; + let pieces = |label| { + let mask: Vec = labels.iter().map(|&l| l == label).collect(); + outline(&loop_of(&mask, w, h), w, h, &across, &grid) + }; + let rotate = |mut p: Point| { + for _ in 0..turns { + p = Point::new(h as f64 - p.y, p.x); + } + p + }; + let (start, end) = (rotate(Point::new((5 - distance) as f64, 4.0)), rotate(Point::new(5.0, 12.0))); + let edge = |pieces: &[Piece]| { + pieces + .iter() + .find(|p| { + p.pts.first() == Some(&start) && p.pts.last() == Some(&end) || p.pts.last() == Some(&start) && p.pts.first() == Some(&end) + }) + .unwrap() + .clone() + }; + let (mut ours, theirs) = (edge(&pieces(palette[1])), edge(&pieces(palette[2]))); + ours.pts.reverse(); + assert_eq!(ours, theirs, "distance={distance}, turns={turns}, palette={palette:?}"); + } + } + } + + #[test] + fn junction_one_pixel_before_corner_keeps_shared_edge_equal() { + check_junction_near_corner(1); + } + + #[test] + fn junction_two_pixels_before_corner_keeps_shared_edge_equal() { + check_junction_near_corner(2); + } + #[test] fn two_regions_get_the_same_points_for_their_shared_edge() { // Left and right halves meet along a staircase; each sees the other's shifts negated. From dbcd32d21a1bc82a51899c27920aaabfedaf2e3b Mon Sep 17 00:00:00 2001 From: andeecollard Date: Thu, 15 Oct 2026 18:14:17 +0000 Subject: [PATCH 2/4] Image Trace: grayscale gradients use grey stops In Grayscale mode with Gradients on, the trace could fill paths with coloured linear gradients (for example a blue-to-orange ramp) beside grey flat fills. Gradient detection ran on the RGB image, so the stops and the gradient's palette colour (its middle) were the source colours, while Grayscale's flat fills come from each pixel's luma. Grayscale now finds gradients, and settles their edges, in a copy of the image reduced to the same Rec. 601 luma the flat grays are quantised from. Every channel of that copy is equal, so the fitted stops, the ends carried to the area's edges and the palette entry are all grey. Color mode is unchanged: it still detects on the RGB image (trace output for gradient fixtures is byte-identical before and after). Adds grayscale_gradients_are_grey: a code-drawn sky ramp over flat shapes with a ramp rectangle, traced in Grayscale with 4 grays and Gradients on, must still produce a gradient, and every path colour, gradient stop and palette entry must be grey. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018tBcdwNhptqtmX32JCGFzB --- crates/trace/src/lib.rs | 16 ++++++++++++++-- crates/trace/src/quantize.rs | 3 ++- crates/trace/src/tests.rs | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/crates/trace/src/lib.rs b/crates/trace/src/lib.rs index 6ea7b689e..9e8157083 100644 --- a/crates/trace/src/lib.rs +++ b/crates/trace/src/lib.rs @@ -291,6 +291,7 @@ pub struct TraceParams { pub auto_grouping: bool, /// Gradients (Grayscale and Color): an area whose colour changes evenly along one direction /// is one path filled with a linear gradient ([`TracedPath::gradient`]), not bands of colour. + /// Grayscale finds them in the image's grays, and their stops are gray. pub gradients: bool, /// Create: trace areas as filled shapes. pub fills: bool, @@ -527,7 +528,17 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result let smoothed = (params.mode != Mode::BlackAndWhite && quantize::continuous_tone(img)).then(|| smooth::smoothed(img)); let img = smoothed.as_ref().unwrap_or(img); // Gradients are found first and kept out of the palette: no colour is spent on bands of them. - let found = (params.gradients && params.mode != Mode::BlackAndWhite).then(|| gradient::detect(img)).filter(|f| !f.1.is_empty()); + // Grayscale finds them in the image's grays (as its flat colours are), so their stops are gray. + let grays = (params.gradients && params.mode == Mode::Grayscale).then(|| { + let mut grays = img.clone(); + for p in grays.rgba.as_chunks_mut::<4>().0 { + let g = quantize::luma(*p); + p[..3].fill(g); + } + grays + }); + let traced_as = grays.as_ref().unwrap_or(img); + let found = (params.gradients && params.mode != Mode::BlackAndWhite).then(|| gradient::detect(traced_as)).filter(|f| !f.1.is_empty()); let without = found.as_ref().map(|(of, _)| { let mut rest = img.clone(); for (p, &g) in rest.rgba.as_chunks_mut::<4>().0.iter_mut().zip(of) { @@ -542,7 +553,7 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result // Each gradient is a palette entry of its own (its colour: the gradient's middle). let mut gradients: Vec> = vec![None; q.palette.len()]; if let Some((mut of, found)) = found { - gradient::settle_edges(img, &mut of, &found, &mut q.labels); + gradient::settle_edges(traced_as, &mut of, &found, &mut q.labels); let first = q.palette.len(); for (l, &g) in q.labels.iter_mut().zip(&of) { if g != u32::MAX { @@ -552,6 +563,7 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result q.palette.extend(found.iter().map(|g| g.color_at(g.start.midpoint(g.end)).map(|v| v.round().clamp(0.0, 255.0) as u8))); gradients.extend(found.into_iter().map(Some)); } + drop(grays); denoise(&mut q.labels, w, h, params.noise as usize); // Pixel count per palette entry, and the box its pixels lie in. let mut counts = vec![0usize; q.palette.len()]; diff --git a/crates/trace/src/quantize.rs b/crates/trace/src/quantize.rs index 476a212eb..7c14e3dde 100644 --- a/crates/trace/src/quantize.rs +++ b/crates/trace/src/quantize.rs @@ -14,7 +14,8 @@ pub struct Quantized { pub palette: Vec<[u8; 3]>, } -fn luma(p: [u8; 4]) -> u8 { +/// A pixel's gray (Rec. 601 luma): what Grayscale traces it as. +pub(crate) fn luma(p: [u8; 4]) -> u8 { ((p[0] as u32 * 299 + p[1] as u32 * 587 + p[2] as u32 * 114 + 500) / 1000) as u8 } diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs index 3f8fb0f5c..59e49bd8c 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -1295,3 +1295,36 @@ fn stroke_caps_are_square_or_round_as_the_ink_is() { assert!(line(true)); assert!(!line(false)); } + +/// A blue-to-orange sky over flat colours, and a green-to-magenta ramp in a rectangle on them. +fn sky_and_ramp() -> Raster { + Raster::from_fn(240, 160, |x, y| { + let (fx, fy) = (f64::from(x), f64::from(y)); + let mix = |a: [f64; 3], b: [f64; 3], t: f64| [0, 1, 2].map(|k| (a[k] + (b[k] - a[k]) * t).round() as u8); + if (20..100).contains(&x) && (100..150).contains(&y) { + let [r, g, b] = mix([40.0, 200.0, 60.0], [220.0, 40.0, 200.0], (fx - 20.0) / 80.0); + [r, g, b, 255] + } else if y >= 90 { + if x >= 140 { [200, 30, 40, 255] } else { [30, 120, 50, 255] } + } else { + let [r, g, b] = mix([30.0, 60.0, 200.0], [250.0, 140.0, 30.0], fy / 90.0); + [r, g, b, 255] + } + }) +} + +#[test] +fn grayscale_gradients_are_grey() { + let img = sky_and_ramp(); + let params = TraceParams { mode: Mode::Grayscale, colors: 4, gradients: true, ..TraceParams::default() }; + let res = trace(&img, ¶ms); + let grey = |c: [u8; 3]| c[0] == c[1] && c[1] == c[2]; + assert!(res.paths.iter().any(|p| p.gradient.is_some()), "a gradient is still found"); + for p in &res.paths { + assert!(grey(p.color), "{:?}", p.color); + for stop in p.gradient.iter().flat_map(|g| &g.stops) { + assert!(grey(stop.1), "{:?}", p.gradient); + } + } + assert!(res.palette.iter().all(|&c| grey(c)), "{:?}", res.palette); +} From 8fffa4f6f9515739005d2f9c12ddb07770351a74 Mon Sep 17 00:00:00 2001 From: andeecollard Date: Thu, 15 Oct 2026 18:08:54 +0000 Subject: [PATCH 3/4] Image Trace: regression tests for shared closed boundaries Abutting traces must draw each edge between two neighbouring regions once: the filled paths on either side follow one curve, one of them reversed, and together cover the image without gaps or overlaps. These tests check that from the trace output alone, with no renderer: points along each path's outline must lie within 0.001 px of another path's outline, the paths' areas must sum to the image's, and every point of a 4 x 4 per-pixel grid must lie in exactly one path. The images are drawn in code, hard-edged and anti-aliased, and traced in Color (Abutting) and Black and White, each at the default settings, Paths and Corners 0 and 100, Snap Curves To Lines, and Shapes. They cover a closed island in a background, a ring with and without a core of a third colour, a wavy two-colour seam, a three-region junction, outlines that leave a junction and return to it (the 111 / 101 / 110 pattern at 1, 2, 3 and 6 px, and framed), squares touching at a corner, a one-pixel hole and a one-pixel island (kept at Noise 1, dropped together with what they leave at Noise 25), separate areas of one colour, and slanted hatches with one-pixel paper channels. A further test shows the check finds a 0.01 px shift and stacked (Overlapping) layers. Two tests are ignored as documented current gaps: - At Paths 0 and Corners 0, a piece returning to its own junction collapses: its region is dropped and its area left uncovered (draft #2205 fixes it). - With Shapes, a region whose outline runs through a junction becomes an exact shape its neighbour does not share (up to 0.0039 px in Black and White). No tracer code changes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018tBcdwNhptqtmX32JCGFzB --- crates/trace/tests/shared_boundaries.rs | 538 ++++++++++++++++++++++++ 1 file changed, 538 insertions(+) create mode 100644 crates/trace/tests/shared_boundaries.rs diff --git a/crates/trace/tests/shared_boundaries.rs b/crates/trace/tests/shared_boundaries.rs new file mode 100644 index 000000000..89e69768f --- /dev/null +++ b/crates/trace/tests/shared_boundaries.rs @@ -0,0 +1,538 @@ +//! Shared boundaries in Abutting traces: every edge between two neighbouring regions is drawn +//! once, so the two filled paths on either side of it follow one curve (one of them reversed), and +//! together the paths cover the image with no gap and no overlap. +//! +//! That must hold for edges between two junctions, for closed edges with no junction at all (an +//! island inside another region, a hole), and for an edge that leaves a junction and comes back to +//! it (two parts of a region touching only at a pixel corner). No region may vanish on its own: a +//! one-pixel island or hole is kept or dropped together with the hole or island it leaves, and two +//! separate areas of one colour stay two shapes. +//! +//! Every image here is drawn in code from simple geometry (squares, discs, sine waves, stripes), +//! hard-edged (each pixel the colour at its centre) and, where it means something, anti-aliased +//! (each pixel the mean of 8 × 8 samples). The check is geometric and needs no renderer: points +//! along each path's outline must lie on another path's outline (to [`TOL`] px), the paths' areas +//! must add up to the image's, and points on a fine grid must each lie in exactly one path. +//! +//! Behaviour reference: Image Trace's Advanced › Method, Abutting (shapes cut out of each other, +//! meeting edge to edge) as against Overlapping (stacked) +//! (). +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveExtrema, ParamCurveNearest, Point, Rect, Shape}; +use vectorcraft_trace::{Method, Mode, Raster, TraceParams, TraceResult, trace}; + +/// How far (px) a point of one path's outline may lie from its neighbour's outline. +const TOL: f64 = 1e-3; + +/// The drawing colours, by index. Far apart, and no blend of two is near a third. +const COLOURS: [[u8; 3]; 3] = [[255, 255, 255], [200, 40, 50], [30, 90, 200]]; + +/// Black and White's two colours: index 0 is paper, 1 is ink. +const BW: [[u8; 3]; 2] = [[255, 255, 255], [0, 0, 0]]; + +/// A drawing: the colour index at a point of a `w × h` image. +struct Art { + name: &'static str, + w: u32, + h: u32, + at: Box usize>, +} + +impl Art { + fn new(name: &'static str, w: u32, h: u32, at: impl Fn(f64, f64) -> usize + 'static) -> Self { + Self { name, w, h, at: Box::new(at) } + } + /// Each pixel the colour at its centre. + fn labels(&self) -> Vec { + (0..self.h).flat_map(|y| (0..self.w).map(move |x| (x, y))).map(|(x, y)| (self.at)(f64::from(x) + 0.5, f64::from(y) + 0.5)).collect() + } + fn hard(&self, colours: &[[u8; 3]]) -> Raster { + Raster::from_fn(self.w, self.h, |x, y| { + let c = colours[(self.at)(f64::from(x) + 0.5, f64::from(y) + 0.5)]; + [c[0], c[1], c[2], 255] + }) + } + /// Anti-aliased: each pixel the mean of its 8 × 8 samples. + fn soft(&self, colours: &[[u8; 3]]) -> Raster { + Raster::from_fn(self.w, self.h, |x, y| { + let mut sum = [0.0f64; 3]; + for i in 0..64 { + let c = colours[(self.at)(f64::from(x) + (f64::from(i % 8) + 0.5) / 8.0, f64::from(y) + (f64::from(i / 8) + 0.5) / 8.0)]; + for k in 0..3 { + sum[k] += f64::from(c[k]) / 64.0; + } + } + [sum[0].round() as u8, sum[1].round() as u8, sum[2].round() as u8, 255] + }) + } +} + +/// The 4-connected components of each colour of `labels`. +fn components(labels: &[usize], w: usize, h: usize, colours: usize) -> Vec { + let mut seen = vec![false; labels.len()]; + let mut count = vec![0; colours]; + for start in 0..labels.len() { + if seen[start] { + continue; + } + count[labels[start]] += 1; + seen[start] = true; + let mut stack = vec![start]; + while let Some(i) = stack.pop() { + let (x, y) = (i % w, i / w); + let near = [(x > 0).then(|| i - 1), (x + 1 < w).then_some(i + 1), (y > 0).then(|| i - w), (y + 1 < h).then_some(i + w)]; + for j in near.into_iter().flatten() { + if !seen[j] && labels[j] == labels[i] { + seen[j] = true; + stack.push(j); + } + } + } + } + count +} + +/// The size of the smallest 4-connected one-colour component of `labels`. +fn smallest(labels: &[usize], w: usize, h: usize) -> usize { + let mut seen = vec![false; labels.len()]; + let mut least = usize::MAX; + for start in 0..labels.len() { + if seen[start] { + continue; + } + seen[start] = true; + let (mut stack, mut size) = (vec![start], 0); + while let Some(i) = stack.pop() { + size += 1; + let (x, y) = (i % w, i / w); + let near = [(x > 0).then(|| i - 1), (x + 1 < w).then_some(i + 1), (y > 0).then(|| i - w), (y + 1 < h).then_some(i + w)]; + for j in near.into_iter().flatten() { + if !seen[j] && labels[j] == labels[i] { + seen[j] = true; + stack.push(j); + } + } + } + least = least.min(size); + } + least +} + +/// Color mode, Abutting, `n` colours, Noise `noise` (0 and 1 remove nothing). +fn color(n: u32, noise: u32) -> TraceParams { + TraceParams { mode: Mode::Color, colors: n, noise, method: Method::Abutting, ignore_white: false, ..TraceParams::default() } +} + +/// Black and White with white traced too, Noise `noise`. +fn black_and_white(noise: u32) -> TraceParams { + TraceParams { mode: Mode::BlackAndWhite, noise, method: Method::Abutting, ignore_white: false, ..TraceParams::default() } +} + +/// What the check found. +#[derive(Debug)] +struct Seams { + /// Paths of each drawing colour (each path goes to the nearest one). + faces: Vec, + /// The farthest a point of one path's outline lies from every other path's outline (px), and + /// where. + gap: f64, + at: Point, + /// The paths' areas summed, less the image's (px²): below 0 a gap, above 0 an overlap. + area: f64, + /// Points of a 4 × 4 grid per pixel that no path covers, and that more than one path covers. + uncovered: usize, + doubled: usize, +} + +impl Seams { + fn ok(&self) -> bool { + self.gap <= TOL && self.area.abs() <= TOL && self.uncovered == 0 && self.doubled == 0 + } +} + +fn segments(p: &vectorcraft_trace::TracedPath) -> Vec { + p.path.subpaths.iter().flat_map(|sp| (0..sp.segment_count()).map(move |i| sp.segment(i))).collect() +} + +fn check(res: &TraceResult, w: u32, h: u32, colours: &[[u8; 3]]) -> Seams { + let (wf, hf) = (f64::from(w), f64::from(h)); + let mut faces = vec![0; colours.len()]; + for p in &res.paths { + assert!(p.stroke.is_none(), "Abutting fills only"); + let d = |c: &[u8; 3]| (0..3).map(|k| (f64::from(c[k]) - f64::from(p.color[k])).powi(2)).sum::(); + let nearest = (0..colours.len()).min_by(|&a, &b| d(&colours[a]).total_cmp(&d(&colours[b]))).unwrap(); + faces[nearest] += 1; + } + let segs: Vec> = + res.paths.iter().map(|p| segments(p).into_iter().map(|c| (c, ParamCurveExtrema::bounding_box(&c))).collect()).collect(); + // Points along each outline, against every other path's outline (the frame has no neighbour). + let on_frame = |p: Point| p.x.abs() < 1e-9 || p.y.abs() < 1e-9 || (p.x - wf).abs() < 1e-9 || (p.y - hf).abs() < 1e-9; + let (mut gap, mut at) = (0.0f64, Point::ORIGIN); + for (i, own) in segs.iter().enumerate() { + for (c, _) in own { + for k in 0..32 { + let p = c.eval(f64::from(k) / 32.0); + if on_frame(p) { + continue; + } + let mut best = f64::INFINITY; + for other in segs.iter().enumerate().filter(|(j, _)| *j != i).map(|(_, o)| o) { + for (o, b) in other { + let dx = (b.x0 - p.x).max(p.x - b.x1).max(0.0); + let dy = (b.y0 - p.y).max(p.y - b.y1).max(0.0); + if dx * dx + dy * dy >= best * best { + continue; + } + best = best.min(o.nearest(p, 1e-12).distance_sq.sqrt()); + } + } + if best > gap { + (gap, at) = (best, p); + } + } + } + } + let area = res.paths.iter().map(|p| p.path.subpaths.iter().map(|s| s.area()).sum::()).sum::() - wf * hf; + let beziers: Vec = res.paths.iter().map(|p| p.path.to_bezpath()).collect(); + let (mut uncovered, mut doubled) = (0, 0); + for yi in 0..h * 4 { + for xi in 0..w * 4 { + // (Off the half and quarter pixels, where hard edges lie.) + let p = Point::new(0.1093 + f64::from(xi) * 0.25, 0.1371 + f64::from(yi) * 0.25); + match beziers.iter().filter(|b| b.winding(p) != 0).count() { + 0 => uncovered += 1, + 1 => {} + _ => doubled += 1, + } + } + } + Seams { faces, gap, at, area, uncovered, doubled } +} + +/// One trace of `art` checked: a line for the record, and what is wrong (empty when nothing is). +/// `expect`: the paths of each colour, where every region must survive. +fn run(art: &Art, soft: bool, bw: bool, params: &TraceParams, setting: &str, expect: Option<&[usize]>) -> (String, Vec) { + let colours: &[[u8; 3]] = if bw { &BW } else { &COLOURS[..params.colors as usize] }; + let img = if soft { art.soft(colours) } else { art.hard(colours) }; + let res = trace(&img, params); + let s = check(&res, art.w, art.h, colours); + let label = format!("{} {} {} noise {} {setting}", art.name, if soft { "soft" } else { "hard" }, if bw { "B&W" } else { "Color" }, params.noise); + let line = format!( + "{label}: faces {:?}, gap {:.6} px at ({:.3}, {:.3}), area {:+.6} px², uncovered {}, doubled {}", + s.faces, s.gap, s.at.x, s.at.y, s.area, s.uncovered, s.doubled + ); + let mut wrong = vec![]; + if !s.ok() { + wrong.push(line.clone()); + } + if let Some(e) = expect + && s.faces != e + { + wrong.push(format!("{label}: faces {:?}, expected {e:?}", s.faces)); + } + (line, wrong) +} + +/// A fitting setting: its name, and what it changes. +type Setting = (&'static str, fn(&mut TraceParams)); + +const DEFAULT: Setting = ("default", |_| {}); +const LOOSEST: Setting = ("paths 0 corners 0", |p| (p.paths, p.corners) = (0.0, 0.0)); +const TIGHTEST: Setting = ("paths 100 corners 100", |p| (p.paths, p.corners) = (100.0, 100.0)); +const SNAP: Setting = ("snap", |p| p.snap_curves_to_lines = true); +const SHAPES: Setting = ("shapes", |p| p.shapes = true); + +/// The defaults, both ends of Paths and Corners, Snap Curves To Lines, and Shapes. +const ALL: [Setting; 5] = [DEFAULT, LOOSEST, TIGHTEST, SNAP, SHAPES]; + +/// Runs every variant of `art` with each of `settings`: hard-edged always, anti-aliased when +/// `soft`, Color with `n` colours, and Black and White when the art has two. Hard-edged, every +/// region must survive (Noise 1 removes nothing), except that at Paths 0 a region of under four +/// pixels may go, if what it leaves goes with it (the geometric checks see to that). +fn variants(art: &Art, n: usize, soft: bool, settings: &[Setting]) -> Vec { + let labels = art.labels(); + let counts = components(&labels, art.w as usize, art.h as usize, n); + let tiny = smallest(&labels, art.w as usize, art.h as usize) < 4; + let mut wrong = vec![]; + let mut runs = vec![(false, false, color(n as u32, 1))]; + if n == 2 { + runs.push((false, true, black_and_white(1))); + } + if soft { + runs.push((true, false, color(n as u32, 1))); + if n == 2 { + runs.push((true, true, black_and_white(1))); + } + } + for (soft, bw, params) in runs { + for &(name, set) in settings { + let mut params = params.clone(); + set(&mut params); + let expect = (!soft && !(tiny && name == LOOSEST.0)).then_some(counts.as_slice()); + let (line, w) = run(art, soft, bw, ¶ms, name, expect); + println!("{line}"); + wrong.extend(w); + } + } + wrong +} + +fn all_variants(art: &Art, n: usize, soft: bool) -> Vec { + variants(art, n, soft, &ALL) +} + +fn assert_none(wrong: Vec) { + assert!(wrong.is_empty(), "shared boundaries broken:\n{}", wrong.join("\n")); +} + +// --- The drawings --------------------------------------------------------------------------- + +fn in_disc(x: f64, y: f64, cx: f64, cy: f64, r: f64) -> bool { + (x - cx).powi(2) + (y - cy).powi(2) <= r * r +} + +fn square_island() -> Art { + Art::new("square island", 32, 32, |x, y| usize::from((9.0..23.0).contains(&x) && (8.0..22.0).contains(&y))) +} + +fn disc_island() -> Art { + Art::new("disc island", 32, 32, |x, y| usize::from(in_disc(x, y, 16.3, 15.8, 8.6))) +} + +/// A ring with a core of a third colour in its hole. +fn nested() -> Art { + Art::new("nested ring and core", 40, 40, |x, y| { + if in_disc(x, y, 20.2, 19.7, 6.3) { + 2 + } else if in_disc(x, y, 20.2, 19.7, 13.4) { + 1 + } else { + 0 + } + }) +} + +/// A ring whose hole shows the background: one colour, two separate areas of it. +fn ring() -> Art { + Art::new("ring with a hole", 40, 40, |x, y| usize::from(in_disc(x, y, 20.2, 19.7, 13.4) && !in_disc(x, y, 20.2, 19.7, 6.3))) +} + +fn square_ring() -> Art { + Art::new("square ring", 32, 32, |x, y| { + let outer = (6.0..26.0).contains(&x) && (6.0..26.0).contains(&y); + let inner = (12.0..20.0).contains(&x) && (12.0..20.0).contains(&y); + usize::from(outer && !inner) + }) +} + +fn wavy_seam() -> Art { + Art::new("wavy seam", 32, 32, |x, y| usize::from(x > 16.0 + 3.2 * (y * std::f64::consts::TAU / 14.0 + 0.4).sin())) +} + +/// Left, upper right and lower right meet at one point inside the image. +fn three_regions() -> Art { + Art::new("three-region junction", 32, 32, |x, y| { + if x < 15.4 + 0.15 * (y - 16.0) { + 0 + } else if y < 16.3 + 0.2 * (x - 16.0) { + 1 + } else { + 2 + } + }) +} + +/// The 3 × 3 pattern 111 / 101 / 110, each cell `k` pixels square: the middle hole and the +/// corner of the background touch at one pixel corner, so the outline round the middle leaves that +/// corner and comes back to it. +fn returning(k: u32) -> Art { + let name: &'static str = match k { + 1 => "returning loop 1 px", + 2 => "returning loop 2 px", + 3 => "returning loop 3 px", + _ => "returning loop 6 px", + }; + let kf = f64::from(k); + Art::new(name, 3 * k, 3 * k, move |x, y| { + let (cx, cy) = ((x / kf) as usize, (y / kf) as usize); + usize::from(!matches!((cx, cy), (1, 1) | (2, 2))) + }) +} + +/// The same pattern inside a frame of the ink colour: the returning outline is an inner one. +fn returning_framed() -> Art { + Art::new("returning loop framed", 24, 24, |x, y| { + let (cx, cy) = (((x - 3.0) / 6.0).floor() as i32, ((y - 3.0) / 6.0).floor() as i32); + usize::from(!matches!((cx, cy), (1, 1) | (2, 2))) + }) +} + +fn one_pixel_hole() -> Art { + Art::new("one-pixel hole", 24, 24, |x, y| { + usize::from((5.0..19.0).contains(&x) && (5.0..19.0).contains(&y) && !(x as u32 == 11 && y as u32 == 12)) + }) +} + +fn one_pixel_island() -> Art { + Art::new("one-pixel island", 15, 15, |x, y| usize::from(x as u32 == 7 && y as u32 == 7)) +} + +fn disconnected_squares() -> Art { + Art::new("disconnected squares", 24, 20, |x, y| { + usize::from(((3.0..7.0).contains(&x) && (3.0..7.0).contains(&y)) || ((16.0..20.0).contains(&x) && (12.0..16.0).contains(&y))) + }) +} + +fn disconnected_discs() -> Art { + Art::new("disconnected discs", 40, 24, |x, y| usize::from(in_disc(x, y, 10.3, 11.7, 6.2) || in_disc(x, y, 28.6, 12.2, 5.4))) +} + +/// Two squares of one colour that touch only at a corner: two regions, not one. +fn corner_contact() -> Art { + Art::new("corner contact", 24, 24, |x, y| { + usize::from(((4.0..12.0).contains(&x) && (4.0..12.0).contains(&y)) || ((12.0..20.0).contains(&x) && (12.0..20.0).contains(&y))) + }) +} + +/// Slanted hatch strokes two pixels wide, one pixel of paper between them (along each row). +fn hatches() -> Art { + Art::new("parallel hatches", 32, 32, |x, y| { + let inside = (4.0..28.0).contains(&x) && (4.0..28.0).contains(&y); + usize::from(inside && (x - (y / 2.0).floor()).rem_euclid(3.0) < 2.0) + }) +} + +/// Upright strokes two pixels wide with one-pixel paper channels, closed at both ends by a bar: +/// each channel is a hole. +fn closed_channels() -> Art { + Art::new("closed paper channels", 32, 24, |x, y| { + let frame = (4.0..28.0).contains(&x) && (4.0..20.0).contains(&y); + let bar = !(6.0..18.0).contains(&y); + usize::from(frame && (bar || (x - 4.0).rem_euclid(3.0) < 2.0 || x >= 25.0)) + }) +} + +// --- The tests ------------------------------------------------------------------------------ + +#[test] +fn a_closed_island_shares_its_whole_outline_with_the_background() { + let mut wrong = all_variants(&square_island(), 2, false); + wrong.extend(all_variants(&disc_island(), 2, true)); + assert_none(wrong); +} + +#[test] +fn a_ring_shares_its_outline_and_its_hole_with_its_neighbours() { + let mut wrong = all_variants(&nested(), 3, true); + wrong.extend(all_variants(&ring(), 2, true)); + wrong.extend(all_variants(&square_ring(), 2, false)); + assert_none(wrong); +} + +#[test] +fn a_wavy_seam_is_one_curve_for_both_sides() { + assert_none(all_variants(&wavy_seam(), 2, true)); +} + +#[test] +fn three_regions_share_their_junction_and_edges() { + assert_none(all_variants(&three_regions(), 3, true)); +} + +/// The drawings whose outlines leave a junction and come back to it. +fn returning_arts() -> Vec { + vec![returning(1), returning(2), returning(3), returning(6), returning_framed(), corner_contact()] +} + +#[test] +fn an_outline_returning_to_its_junction_is_shared() { + let mut wrong = vec![]; + for art in returning_arts() { + wrong.extend(variants(&art, 2, false, &[DEFAULT, TIGHTEST, SNAP])); + } + assert_none(wrong); +} + +/// At Paths 0 and Corners 0, a piece that leaves a junction and returns to it is fitted with fewer +/// than two curves: the region it is the whole outline of is dropped (a closed subpath needs two), +/// and its neighbour keeps a single curve from the junction back to itself, so a hole the size of +/// the region is left uncovered (1 to 6.7 px wide here, 0.3 to 108 px²). +#[test] +#[ignore = "a piece returning to its own junction collapses at Paths 0 / Corners 0 (fit.rs fit_piece and fit_loop): the region is dropped and its area left uncovered; draft #2205 (retain pieces returning to a junction) fixes it"] +fn an_outline_returning_to_its_junction_survives_the_loosest_fit() { + let mut wrong = vec![]; + for art in returning_arts() { + wrong.extend(variants(&art, 2, false, &[LOOSEST])); + } + assert_none(wrong); +} + +/// With Shapes, a region whose outline is a shape becomes that exact shape, but where its outline +/// runs through a junction (two parts of the other colour touch at a corner) the neighbour keeps +/// the traced curve: in Black and White (where the threshold moves hard edges by 1/256 px) the +/// square and its neighbour part by up to 0.0039 px, and a square on the image's corner reaches +/// 0.002 px past the frame. +#[test] +#[ignore = "Shapes replaces a region touching a junction with an exact shape that its neighbour does not share (lib.rs Layers::layer, the Shapes branch): up to 0.0039 px apart in Black and White"] +fn shapes_keep_the_edges_a_region_shares_at_a_junction() { + let mut wrong = vec![]; + for art in returning_arts() { + wrong.extend(variants(&art, 2, false, &[SHAPES])); + } + assert_none(wrong); +} + +#[test] +fn a_one_pixel_hole_and_island_survive_with_what_they_leave() { + let mut wrong = all_variants(&one_pixel_hole(), 2, false); + wrong.extend(all_variants(&one_pixel_island(), 2, false)); + // At the default Noise both go, each together with the hole it would leave. + // (The hole goes into the square around it; the island into the background, leaving no hole.) + for (art, expect_island) in [(one_pixel_hole(), 1), (one_pixel_island(), 0)] { + for (bw, params) in [(false, color(2, 25)), (true, black_and_white(25))] { + let (line, w) = run(&art, false, bw, ¶ms, "default", Some(&[1, expect_island])); + println!("{line}"); + wrong.extend(w); + } + } + assert_none(wrong); +} + +#[test] +fn separate_areas_of_one_colour_stay_separate() { + let mut wrong = all_variants(&disconnected_squares(), 2, false); + wrong.extend(all_variants(&disconnected_discs(), 2, true)); + // (Squares touching at a corner: see the returning-outline tests.) + assert_none(wrong); +} + +#[test] +fn one_pixel_paper_channels_between_hatches_are_shared() { + let mut wrong = all_variants(&hatches(), 2, true); + wrong.extend(all_variants(&closed_channels(), 2, false)); + assert_none(wrong); +} + +/// The check itself: a path moved by a hundredth of a pixel, and stacked (Overlapping) layers, are +/// found. +#[test] +fn the_check_finds_a_hairline_gap_and_an_overlap() { + let art = square_island(); + let colours = &COLOURS[..2]; + let mut res = trace(&art.hard(colours), &color(2, 1)); + let s = check(&res, art.w, art.h, colours); + assert!(s.ok(), "{s:?}"); + let island = res.paths.iter_mut().find(|p| p.color == colours[1]).unwrap(); + for a in &mut island.path.subpaths[0].anchors { + for p in [&mut a.p, &mut a.h_in, &mut a.h_out] { + p.x += 0.01; + } + } + let s = check(&res, art.w, art.h, colours); + assert!(!s.ok() && (s.gap - 0.01).abs() < 1e-6, "{s:?}"); + let stacked = TraceParams { method: Method::Overlapping, ..color(2, 1) }; + let s = check(&trace(&art.hard(colours), &stacked), art.w, art.h, colours); + assert!(s.doubled > 0 && s.area > 100.0, "{s:?}"); +} From 032b7b7be16e2e7fbc00c21d630a3f0f56303422 Mon Sep 17 00:00:00 2001 From: turtwig Date: Wed, 14 Oct 2026 11:00:39 +0100 Subject: [PATCH 4/4] Glyphs panel: zoom buttons resize the preview and the step is kept Zoom Out and Zoom In sit to the right of the font family and style menus. They step the preview cells through 16, 22, 30, 42, 56, 72 and 96 pt; the grid still opens at 30 pt, and the buttons stop at the ends. The step is stored on the UI state and written with the other panel choices in ui.json when the app quits. Zero is the default and is left out of the file, so older preferences stay as they are. --- crates/ui-egui/src/panels/glyphs.rs | 203 ++++++++++++++++++++++++++-- crates/ui-egui/src/state.rs | 9 ++ 2 files changed, 202 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/panels/glyphs.rs b/crates/ui-egui/src/panels/glyphs.rs index da2230187..a5675d7e0 100644 --- a/crates/ui-egui/src/panels/glyphs.rs +++ b/crates/ui-egui/src/panels/glyphs.rs @@ -4,7 +4,7 @@ use std::collections::HashMap; -use egui::{Color32, Sense, Ui, vec2}; +use egui::{Color32, Sense, Stroke, StrokeKind, Ui, vec2}; use serde_json::json; use vectorcraft_geom::{Affine, BezPath, PathEl}; use vectorcraft_text::FontDb; @@ -14,7 +14,34 @@ use crate::VectorcraftApp; use crate::theme::Tokens; use crate::widgets::{self, menu_item}; -const CELL: f32 = 30.0; +/// Preview cell sizes for the glyph grid, smallest to largest. The third step (30 pt) is the size +/// the grid used before Zoom Out and Zoom In. Public behaviour: [Glyphs panel overview](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/glyphs-panel-overview.html) +/// (Zoom Out reduces the preview, Zoom In increases it). The step sizes themselves are ours. +const CELL_STEPS: [f32; 7] = [16.0, 22.0, 30.0, 42.0, 56.0, 72.0, 96.0]; +const CELL_DEFAULT: usize = 2; + +/// The grid step in [`crate::state::UiState::glyphs_preview`]. `0` means [`CELL_DEFAULT`]; a +/// stored step is `index + 1`. A value past the steps (a hand-edited preferences file) is the +/// default too. +fn cell_index(app: &VectorcraftApp) -> usize { + match usize::from(app.ui.glyphs_preview) { + 0 => CELL_DEFAULT, + n if n <= CELL_STEPS.len() => n - 1, + _ => CELL_DEFAULT, + } +} + +fn set_cell_index(app: &mut VectorcraftApp, index: usize) { + let index = index.min(CELL_STEPS.len() - 1); + // `index + 1` is at most `CELL_STEPS.len()` (7), which fits in a `u8`. + app.ui.glyphs_preview = (index + 1) as u8; +} + +/// One step toward a smaller (`-1`) or larger (`1`) preview, staying inside [`CELL_STEPS`]. +fn nudge_cell(index: usize, delta: i32) -> usize { + let last = CELL_STEPS.len() as i32 - 1; + (index as i32 + delta).clamp(0, last) as usize +} /// Character subsets offered by the Show menu. const SUBSETS: [(&str, u32, u32); 7] = [ @@ -156,17 +183,18 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { }); let chars: Vec<(char, u32)> = face.chars().into_iter().filter(|(c, _)| (lo..=hi).contains(&(*c as u32)) && !c.is_control() && !c.is_whitespace()).collect(); - let cols = ((w / CELL).floor() as usize).max(1); + let cell = CELL_STEPS[cell_index(app)]; + let cols = ((w / cell).floor() as usize).max(1); let rows = chars.len().div_ceil(cols); - let px = (CELL * ui.ctx().pixels_per_point()).round() as u32; - let list_h = (ui.available_height() - 64.0).max(CELL * 3.0); + let px = (cell * ui.ctx().pixels_per_point()).round().clamp(8.0, 256.0) as u32; + let list_h = (ui.available_height() - 64.0).max(cell * 3.0); let hover_id = egui::Id::new("gl-hover"); - egui::ScrollArea::vertical().max_height(list_h).auto_shrink([false, false]).show_rows(ui, CELL, rows, |ui, range| { + egui::ScrollArea::vertical().max_height(list_h).auto_shrink([false, false]).show_rows(ui, cell, rows, |ui, range| { for r in range { ui.horizontal(|ui| { ui.spacing_mut().item_spacing = vec2(0.0, 0.0); for &(c, gid) in chars.iter().skip(r * cols).take(cols) { - let (rect, resp) = ui.allocate_exact_size(vec2(CELL, CELL), Sense::click()); + let (rect, resp) = ui.allocate_exact_size(vec2(cell, cell), Sense::click()); if resp.hovered() { ui.painter().rect_filled(rect, 2, t.hover); ui.ctx().data_mut(|d| d.insert_temp(hover_id, (c, gid))); @@ -198,22 +226,52 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { .color(t.text_dim), ); ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; // Picks the font the panel browses only: nothing is previewed on the document. + // Zoom Out and Zoom In sit to the right of the family and style menus. + let btn = 22.0; + let menus = (w - btn * 2.0 - 12.0).max(88.0); + let family_w = (menus * 0.62).clamp(48.0, (menus - 40.0).max(48.0)); + let style_w = (menus - family_w).max(40.0); let sample = crate::font_menu::sample_text(app); let look = crate::font_menu::MenuLook::of(app); - let pick = crate::font_menu::font_menu(ui, "gl-family", &family, (w * 0.6).max(80.0), sample.as_deref(), look); + let pick = crate::font_menu::font_menu(ui, "gl-family", &family, family_w, sample.as_deref(), look); if let Some((f, style)) = crate::font_menu::picked(app, pick) { let st = style.unwrap_or_else(|| db.face(&f, "Regular").map_or_else(|| "Regular".into(), |face| face.style.clone())); set_pstate(ui.ctx(), "gl-font", Some((f, st))); } let styles = db.styles(&family); let snames: Vec<&str> = styles.iter().map(String::as_str).collect(); - if let Some(i) = widgets::dropdown_names(ui, "gl-style", &style, &snames, (w * 0.38 - 8.0).max(60.0)) { - set_pstate(ui.ctx(), "gl-font", Some((family.clone(), snames[i].to_string()))); + if let Some(i) = widgets::dropdown_names(ui, "gl-style", &style, &snames, style_w) + && let Some(name) = snames.get(i) + { + set_pstate(ui.ctx(), "gl-font", Some((family.clone(), (*name).to_string()))); + } + let step = cell_index(app); + if zoom_button(ui, "out", "−", "Zoom Out", step > 0) { + set_cell_index(app, nudge_cell(step, -1)); + } + if zoom_button(ui, "in", "+", "Zoom In", step + 1 < CELL_STEPS.len()) { + set_cell_index(app, nudge_cell(step, 1)); } }); } +/// A square [ − ] or [ + ] beside the font menus. `label` is the mark itself (not translated). +fn zoom_button(ui: &mut Ui, id: &str, label: &str, tip: &str, enabled: bool) -> bool { + let t = Tokens::get(ui.ctx()); + let (rect, _) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::hover()); + let sense = if enabled { Sense::click() } else { Sense::hover() }; + let resp = ui.interact(rect, ui.id().with(("gl-zoom", id)), sense); + let bg = if enabled && resp.hovered() { t.hover } else { Color32::TRANSPARENT }; + ui.painter().rect_filled(rect, 2, bg); + let border = if enabled { t.button_border } else { t.border }; + ui.painter().rect_stroke(rect.shrink(0.5), 2, Stroke::new(1.0, border), StrokeKind::Inside); + let color = if enabled { t.text_strong } else { t.text_disabled }; + ui.painter().text(rect.center(), egui::Align2::CENTER_CENTER, label, egui::FontId::proportional(14.0), color); + resp.on_hover_text(tl!(tip)).clicked() && enabled +} + pub fn menu(_app: &mut VectorcraftApp, ui: &mut Ui) { if menu_item(ui, tl!("Use the Selected Text's Font"), true, false) { set_pstate::>(ui.ctx(), "gl-font", None); @@ -226,3 +284,128 @@ pub fn menu(_app: &mut VectorcraftApp, ui: &mut Ui) { } } } + +#[cfg(test)] +mod tests { + use egui::{Pos2, Rect, Shape}; + + use super::*; + + fn frame(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec) -> (Vec<(String, Pos2)>, Vec) { + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(280.0, 420.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + let shapes: Vec = out.shapes.into_iter().map(|c| c.shape).collect(); + (text_anchors(&shapes), square_sides(&shapes)) + } + + fn text_anchors(shapes: &[Shape]) -> Vec<(String, Pos2)> { + fn walk(s: &Shape, out: &mut Vec<(String, Pos2)>) { + match s { + Shape::Text(t) => out.push((t.galley.text().to_string(), t.pos)), + Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)), + _ => {} + } + } + let mut out = vec![]; + shapes.iter().for_each(|s| walk(s, &mut out)); + out + } + + /// Sides of the square glyph previews (font-menu icons are smaller). + fn square_sides(shapes: &[Shape]) -> Vec { + fn walk(s: &Shape, out: &mut Vec) { + match s { + Shape::Mesh(m) => { + let r = m.calc_bounds(); + if (r.width() - r.height()).abs() < 1.0 && r.width() >= 14.0 { + out.push(r.width()); + } + } + Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)), + _ => {} + } + } + let mut out = vec![]; + shapes.iter().for_each(|s| walk(s, &mut out)); + out + } + + fn mark(texts: &[(String, Pos2)], label: &str) -> Pos2 { + texts + .iter() + .filter(|(t, _)| t == label) + .map(|(_, p)| *p) + .max_by(|a, b| a.x.total_cmp(&b.x)) + .unwrap_or_else(|| panic!("{label} missing in {texts:?}")) + } + + fn click(ctx: &egui::Context, app: &mut VectorcraftApp, at: Pos2) { + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(ctx, app, vec![egui::Event::PointerMoved(at), button(true)]); + frame(ctx, app, vec![button(false)]); + } + + #[test] + fn zoom_stops_at_the_ends() { + assert_eq!(nudge_cell(CELL_DEFAULT, 1), CELL_DEFAULT + 1); + assert_eq!(nudge_cell(CELL_DEFAULT, -1), CELL_DEFAULT - 1); + assert_eq!(nudge_cell(0, -1), 0); + assert_eq!(nudge_cell(CELL_STEPS.len() - 1, 1), CELL_STEPS.len() - 1); + assert_eq!(CELL_STEPS[CELL_DEFAULT], 30.0); + } + + #[test] + fn zoom_buttons_resize_the_glyph_grid() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + let ctx = egui::Context::default(); + let (texts, sides) = frame(&ctx, &mut app, vec![]); + if texts.iter().any(|(t, _)| t.contains("No fonts are available")) { + eprintln!("glyphs zoom: no fonts installed, grid not drawn"); + return; + } + let (minus, plus) = (mark(&texts, "−"), mark(&texts, "+")); + assert!(minus.x + 8.0 < plus.x, "− then + on the right: {minus:?} {plus:?}"); + let family_x = texts.iter().map(|(_, p)| p.x).fold(f32::MAX, f32::min); + assert!(minus.x > family_x + 40.0, "the buttons sit to the right of the font menus"); + assert!(sides.iter().all(|s| (*s - 30.0).abs() < 1.0), "default cells are 30 pt: {sides:?}"); + assert!(!sides.is_empty(), "the grid drew glyphs"); + click(&ctx, &mut app, plus); + let (_texts, sides) = frame(&ctx, &mut app, vec![]); + assert_eq!(cell_index(&app), CELL_DEFAULT + 1); + assert!(sides.iter().all(|s| (*s - 42.0).abs() < 1.0), "Zoom In grows the cells: {sides:?}"); + click(&ctx, &mut app, minus); + let (_texts, sides) = frame(&ctx, &mut app, vec![]); + assert_eq!(cell_index(&app), CELL_DEFAULT); + assert!(sides.iter().all(|s| (*s - 30.0).abs() < 1.0), "Zoom Out restores 30 pt: {sides:?}"); + // Hold Zoom Out until the smallest step, then once more: it stays there. + for _ in 0..CELL_STEPS.len() { + let (texts, _) = frame(&ctx, &mut app, vec![]); + click(&ctx, &mut app, mark(&texts, "−")); + } + assert_eq!(cell_index(&app), 0); + let (texts, sides) = frame(&ctx, &mut app, vec![]); + assert!(sides.iter().all(|s| (*s - CELL_STEPS[0]).abs() < 1.0), "{sides:?}"); + click(&ctx, &mut app, mark(&texts, "−")); + assert_eq!(cell_index(&app), 0); + } + + #[test] + fn the_preview_step_is_kept_in_the_ui_preferences() { + let mut ui = crate::state::UiState::default(); + assert_eq!(ui.glyphs_preview, 0); + assert!(serde_json::to_value(&ui).unwrap().get("glyphs_preview").is_none(), "the default adds nothing to an older preferences file"); + ui.glyphs_preview = (CELL_DEFAULT + 2) as u8; + let saved = serde_json::to_value(&ui).unwrap(); + assert_eq!(saved["glyphs_preview"], serde_json::json!(CELL_DEFAULT + 2)); + let back: crate::state::UiState = serde_json::from_value(saved).unwrap(); + assert_eq!(back.glyphs_preview, (CELL_DEFAULT + 2) as u8); + let old: crate::state::UiState = serde_json::from_value(serde_json::json!({})).unwrap(); + assert_eq!(old.glyphs_preview, 0); + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.ui.glyphs_preview = back.glyphs_preview; + assert_eq!(cell_index(&app), CELL_DEFAULT + 1); + app.ui.glyphs_preview = 99; + assert_eq!(cell_index(&app), CELL_DEFAULT); + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index d47ac078e..369ff1b76 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -456,6 +456,10 @@ pub struct UiState { /// The Image Trace panel's Advanced section is open (as it was last left). #[serde(default = "yes")] pub image_trace_advanced: bool, + /// Glyphs panel preview step, kept with the other panel choices. `0` is the default 30 pt + /// cell; `1`..=`7` is a step from smallest to largest (`crates/ui-egui/src/panels/glyphs.rs`). + #[serde(default, skip_serializing_if = "is_zero_u8")] + pub glyphs_preview: u8, /// The desktop window's size, position and maximized state, saved when the app quits and /// restored at the next launch (the desktop host reads and writes it; none on the web). #[serde(default, skip_serializing_if = "Option::is_none")] @@ -756,6 +760,7 @@ impl Default for UiState { layers_panel: Default::default(), layers_filter: Default::default(), image_trace_advanced: true, + glyphs_preview: 0, window: None, } } @@ -765,6 +770,10 @@ fn yes() -> bool { true } +fn is_zero_u8(v: &u8) -> bool { + *v == 0 +} + fn default_shortcut_set() -> String { crate::shortcut_editor::PRESETS[0].to_string() }