diff --git a/crates/cad/src/hpgl/write.rs b/crates/cad/src/hpgl/write.rs index 9e77495aa2..b38abd005c 100644 --- a/crates/cad/src/hpgl/write.rs +++ b/crates/cad/src/hpgl/write.rs @@ -16,15 +16,19 @@ //! its center, rounded to whole units. Art past ±8,388,607 units (2²³ − 1, the smallest range //! the HP-GL/2 reference asks a device to take for encoded polylines) from the origin is cut off. //! The pens are drawn in turn (pen 1's paths, then pen 2's), each in paint order or, with -//! `sort`, each next path the one that starts nearest where the pen stopped. +//! `sort`, each next path the one that starts nearest where the pen stopped. With `inside_first`, +//! a pen draws a path only after every path of its own that lies inside it (a letter's counter +//! before the letter, a hole before the part). //! //! HP-GL writes `IN;`, then for each pen `PU;SP;` and per path `PUx,y;PDx,y,…;`, then `PU;SP0;`. //! HP-GL/2 also writes the palette (`NP` and a `PC` color for each pen) and draws each pen's paths //! as one encoded polyline (`PE`, base 64). mod clip; +mod nest; -use std::collections::HashSet; +use std::cmp::Reverse; +use std::collections::{BinaryHeap, HashSet}; use std::sync::Arc; use kurbo::PathEl; @@ -47,7 +51,8 @@ pub const TOLERANCE_RANGE: std::ops::RangeInclusive = 0.01..=10.0; pub const MAX_PENS: usize = 255; /// The most points the plots of one export hold. const MAX_POINTS: usize = if cfg!(test) { 100_000 } else { 10_000_000 }; -/// The most point comparisons sorting may make, over the whole plot. +/// The most work sorting paths and finding which lie inside others may take, over the whole plot: +/// one for each point or box compared. const MAX_SORT_WORK: u64 = if cfg!(test) { 100_000 } else { 500_000_000 }; /// The most edge tests clipping masks may take, over the whole plot. const MAX_CLIP_TESTS: u64 = if cfg!(test) { 1_000_000 } else { 200_000_000 }; @@ -69,6 +74,8 @@ const MASK_POINTS: &str = "clipping masks past 10 million points together are le const OUT_OF_RANGE: &str = "art past 8,388,607 plotter units (about 210 m) from the origin is cut off"; const MANY_COLORS: &str = "the art has more than 255 colors: the colors past those draw with the pen of the nearest color"; const UNSORTED: &str = "the plot has too many paths to sort in full: the rest are drawn in paint order"; +const UNSORTED_INSIDE: &str = "the plot has too many paths to sort in full: the rest are drawn in paint order, each after the paths inside it"; +const UNNESTED: &str = "the plot has too many paths to find in full which lie inside others: from the pen where that stopped, a path may be drawn before the paths inside it"; const TOO_DEEP: &str = "symbols, blends, envelopes and repeats nested more than 8 deep were left out"; const LIVE_LEFT_OUT: &str = "blends, envelopes and repeats make too many objects: the art past 4 million objects was left out"; const SYMBOLS_LEFT_OUT: &str = "symbols placed inside symbols hold too many objects: the instances past that were left out"; @@ -144,6 +151,8 @@ pub struct HpglOptions { pub origin: Origin, /// Draw each pen's paths nearest first, instead of in paint order. pub sort: bool, + /// Draw a path only after the pen's paths that lie inside it. + pub inside_first: bool, /// The area plotted (document space): its bottom-left corner or center is the origin, and the /// art is cut at its edges. pub region: Rect, @@ -151,7 +160,15 @@ pub struct HpglOptions { impl Default for HpglOptions { fn default() -> Self { - Self { version: Version::default(), tolerance: DEFAULT_TOLERANCE, pens: vec![], origin: Origin::default(), sort: false, region: Rect::ZERO } + Self { + version: Version::default(), + tolerance: DEFAULT_TOLERANCE, + pens: vec![], + origin: Origin::default(), + sort: false, + inside_first: false, + region: Rect::ZERO, + } } } @@ -251,14 +268,10 @@ pub fn export_regions(doc: &Document, opts: &HpglOptions, regions: &[Rect]) -> R if !by_pen.iter().all(Vec::is_empty) { pen_warnings.iter().for_each(|w| warn(&mut warnings, w)); } - if opts.sort { + if opts.sort || opts.inside_first { let (mut at, mut work) = ((0, 0), MAX_SORT_WORK); for paths in &mut by_pen { - let (stop, sorted) = nearest_first(paths, at, &mut work); - at = stop; - if !sorted { - warn(&mut warnings, UNSORTED); - } + at = reorder(paths, at, &mut work, opts.sort, opts.inside_first, &mut warnings); } } let bytes = match opts.version { @@ -333,25 +346,50 @@ fn box_dist2(p: (i64, i64), b: [i64; 4]) -> i128 { dx * dx + dy * dy } -/// `paths` reordered from `at`: each next path is the one nearest where the pen stopped, by its -/// start or, for an open path (then drawn backwards), its end, or for a closed path any of its -/// points (where it then starts). Comparing points stops once `work` is spent; the paths left keep -/// their order. Returns where the pen stops, and `false` when the work ran out. -fn nearest_first(paths: &mut Vec>, mut at: (i64, i64), work: &mut u64) -> ((i64, i64), bool) { - // Each path with its box. - let mut left: Vec<_> = std::mem::take(paths) - .into_iter() - .map(|p| { - let b = p.iter().fold([i64::MAX, i64::MAX, i64::MIN, i64::MIN], |b, q| [b[0].min(q.0), b[1].min(q.1), b[2].max(q.0), b[3].max(q.1)]); - (p, b) - }) - .collect(); +/// `paths` (one pen's) reordered from `at`. With `nearest`, each next path is the one nearest +/// where the pen stopped, by its start or, for an open path (then drawn backwards), its end, or +/// for a closed path any of its points (where it then starts); without, the first in paint order. +/// With `inside_first`, a path is drawn only once every path lying inside it ([`nest::outer`]) is. +/// Once `work` is spent, the paths left keep paint order, each after the paths inside it with +/// `inside_first`, with a warning. Returns where the pen stops. +fn reorder( + paths: &mut Vec>, + mut at: (i64, i64), + work: &mut u64, + nearest: bool, + inside_first: bool, + warnings: &mut Vec, +) -> (i64, i64) { + let boxes: Vec<[i64; 4]> = paths.iter().map(|p| nest::bounds(p)).collect(); + // The paths each path lies inside, and how many paths inside each are still to draw. + let outer = inside_first.then(|| nest::outer(paths, &boxes, work)).flatten(); + if inside_first && outer.is_none() { + warn(warnings, UNNESTED); + } + let mut waiting = vec![0usize; paths.len()]; + for o in outer.iter().flatten().flatten() { + if let Some(w) = waiting.get_mut(*o) { + *w += 1; + } + } + if !nearest { + if let Some(outer) = &outer { + let all = std::mem::take(paths).into_iter().enumerate().collect(); + inner_first(all, outer, &mut waiting, paths); + } + return at; + } + // Each path with its index and box. + let mut left: Vec<_> = std::mem::take(paths).into_iter().zip(boxes).enumerate().map(|(i, (p, b))| (i, p, b)).collect(); while !left.is_empty() { // (index, reversed, the closed path's point to start at, distance) - let mut best: (usize, bool, Option, i128) = (0, false, None, i128::MAX); - for (i, (p, b)) in left.iter().enumerate() { + let mut best: (usize, bool, Option, i128) = (usize::MAX, false, None, i128::MAX); + for (i, (j, p, b)) in left.iter().enumerate() { let (Some(&s), Some(&e)) = (p.first(), p.last()) else { continue }; *work = work.saturating_sub(1); + if waiting.get(*j).is_some_and(|w| *w > 0) { + continue; + } if s == e && p.len() > 2 { if box_dist2(at, *b) >= best.3 { continue; @@ -372,11 +410,27 @@ fn nearest_first(paths: &mut Vec>, mut at: (i64, i64), work: &mu best = (i, true, None, de); } } - if *work == 0 { - paths.extend(left.into_iter().map(|(p, _)| p)); - return (at, false); + // Spent, or no path free to draw (which only a fault could cause): the rest in paint + // order, each after the paths inside it. + if *work == 0 || best.0 >= left.len() { + match &outer { + Some(outer) => { + warn(warnings, UNSORTED_INSIDE); + inner_first(left.into_iter().map(|(j, p, _)| (j, p)).collect(), outer, &mut waiting, paths); + } + None => { + warn(warnings, UNSORTED); + paths.extend(left.into_iter().map(|(_, p, _)| p)); + } + } + return at; + } + let (j, mut p, _) = left.remove(best.0); + for o in outer.as_ref().and_then(|outer| outer.get(j)).into_iter().flatten() { + if let Some(w) = waiting.get_mut(*o) { + *w = w.saturating_sub(1); + } } - let (mut p, _) = left.remove(best.0); if best.1 { p.reverse(); } else if let Some(k) = best.2 { @@ -393,7 +447,39 @@ fn nearest_first(paths: &mut Vec>, mut at: (i64, i64), work: &mu } paths.push(p); } - (at, true) + at +} + +/// Appends `left` (each path with its index among the pen's paths) to `paths` in paint order, +/// each after the paths lying inside it: `outer` lists the paths each lies inside, and `waiting` +/// counts, for each, the paths inside it still to draw. [`nest::outer`] puts a path only inside +/// larger ones, so every path comes free; a path left waiting by a fault is drawn last. +fn inner_first(left: Vec<(usize, Vec<(i64, i64)>)>, outer: &[Vec], waiting: &mut [usize], paths: &mut Vec>) { + let mut held: Vec>> = (0..outer.len()).map(|_| None).collect(); + let mut free = BinaryHeap::new(); + for (j, p) in left { + if let Some(h) = held.get_mut(j) { + if waiting.get(j).is_none_or(|w| *w == 0) { + free.push(Reverse(j)); + } + *h = Some(p); + } else { + paths.push(p); + } + } + while let Some(Reverse(j)) = free.pop() { + let Some(p) = held.get_mut(j).and_then(Option::take) else { continue }; + paths.push(p); + for &o in outer.get(j).into_iter().flatten() { + if let Some(w) = waiting.get_mut(o) { + *w = w.saturating_sub(1); + if *w == 0 { + free.push(Reverse(o)); + } + } + } + } + paths.extend(held.into_iter().flatten()); } /// HP-GL: `IN;`, each pen's paths as `PU` and `PD`, `PU;SP0;`. diff --git a/crates/cad/src/hpgl/write/nest.rs b/crates/cad/src/hpgl/write/nest.rs new file mode 100644 index 0000000000..eee4630ff3 --- /dev/null +++ b/crates/cad/src/hpgl/write/nest.rs @@ -0,0 +1,357 @@ +//! Which of a pen's paths lie inside which of its closed paths, in plotter units, for drawing the +//! inner paths first. + +/// The box of `p`: `[x0, y0, x1, y1]`. +pub(super) fn bounds(p: &[(i64, i64)]) -> [i64; 4] { + p.iter().fold([i64::MAX, i64::MAX, i64::MIN, i64::MIN], |b, q| [b[0].min(q.0), b[1].min(q.1), b[2].max(q.0), b[3].max(q.1)]) +} + +/// For each of `paths` (`boxes` are their [`bounds`]), the closed paths it lies inside: none of +/// its segments crosses the closed path's line, each part of it between the points where it meets +/// that line lies inside the closed path (by the even-odd rule) or on the line, and at least one +/// part lies inside. A path touching the line of the one around it is inside it, a path crossing +/// it is not, and copies of one outline are not inside each other. A path is tested only against +/// closed paths larger than it (by the area of their box, then the area they enclose, then paint +/// order), so no two paths are each inside the other, and only against the closed paths in the +/// [`Grid`] cell of its box's first corner. `None` once `work` is spent: a grid cell filled and a +/// box compared cost one each, and each segment tested costs the closed path's points for its +/// crossings and again for each part of it. +pub(super) fn outer(paths: &[Vec<(i64, i64)>], boxes: &[[i64; 4]], work: &mut u64) -> Option>> { + let size: Vec<(i128, i128)> = paths + .iter() + .zip(boxes) + .map(|(p, b)| { + let box_area = (i128::from(b[2]) - i128::from(b[0])) * (i128::from(b[3]) - i128::from(b[1])); + (box_area, if closed(p) { twice_area(p) } else { 0 }) + }) + .collect(); + let rings: Vec = (0..paths.len()).filter(|&i| paths.get(i).is_some_and(|p| closed(p))).collect(); + let mut out = vec![vec![]; paths.len()]; + if rings.is_empty() { + return Some(out); + } + let grid = Grid::new(&rings, boxes, work)?; + for (a, (p, ab)) in paths.iter().zip(boxes).enumerate() { + for &r in grid.at(ab[0], ab[1]) { + spend(work, 1)?; + let (Some(rb), Some(ring)) = (boxes.get(r), paths.get(r)) else { continue }; + let smaller = (size.get(a), a) < (size.get(r), r); + if !smaller || rb[0] > ab[0] || rb[1] > ab[1] || rb[2] < ab[2] || rb[3] < ab[3] { + continue; + } + if inside(p, ring, work)? + && let Some(o) = out.get_mut(a) + { + o.push(r); + } + } + } + Some(out) +} + +/// The most cells along each side of a [`Grid`]. +const MAX_GRID_SIDE: usize = 512; +/// The most cells of a [`Grid`] a closed path is listed in; one whose box covers more is listed +/// once, among the wide ones. A closed path has 4 points or more, so the grid lists no more +/// entries than the pen has points. +const MAX_CELLS_PER_PATH: usize = 4; + +/// A grid over the boxes of a pen's closed paths, about one cell for each, up to +/// [`MAX_GRID_SIDE`] a side: each cell lists, in paint order, the closed paths whose box covers +/// it, and the closed paths whose box covers more than [`MAX_CELLS_PER_PATH`] cells are listed +/// apart. A closed path whose box holds a point is among the wide ones or in that point's cell. +struct Grid { + /// The first corner of the closed paths' boxes together. + x0: i128, + y0: i128, + /// Their width and height, plus one. + w: i128, + h: i128, + /// Cells along each side. + side: usize, + /// The cells row by row. + cells: Vec>, + /// The closed paths whose box covers more than [`MAX_CELLS_PER_PATH`] cells. + wide: Vec, +} + +impl Grid { + /// The grid of the closed paths `rings` (indices into `boxes`). `None` once `work` is spent. + fn new(rings: &[usize], boxes: &[[i64; 4]], work: &mut u64) -> Option { + let all = rings + .iter() + .filter_map(|&r| boxes.get(r)) + .fold([i64::MAX, i64::MAX, i64::MIN, i64::MIN], |e, b| [e[0].min(b[0]), e[1].min(b[1]), e[2].max(b[2]), e[3].max(b[3])]); + let side = ((rings.len() as f64).sqrt().ceil() as usize).clamp(1, MAX_GRID_SIDE); + let (x0, y0) = (i128::from(all[0]), i128::from(all[1])); + let mut grid = Self { + x0, + y0, + w: (i128::from(all[2]) - x0 + 1).max(1), + h: (i128::from(all[3]) - y0 + 1).max(1), + side, + cells: vec![vec![]; side * side], + wide: vec![], + }; + for &r in rings { + let Some(b) = boxes.get(r) else { continue }; + let (Some((cx0, cy0)), Some((cx1, cy1))) = (grid.cell(b[0], b[1]), grid.cell(b[2], b[3])) else { continue }; + let covered = (cx1 - cx0 + 1) * (cy1 - cy0 + 1); + if covered > MAX_CELLS_PER_PATH { + spend(work, 1)?; + grid.wide.push(r); + continue; + } + spend(work, covered as u64)?; + for cy in cy0..=cy1 { + for cell in grid.cells.get_mut(cy * side + cx0..=cy * side + cx1).into_iter().flatten() { + cell.push(r); + } + } + } + Some(grid) + } + + /// The column and row of the cell holding `(x, y)`, `None` outside the grid. + fn cell(&self, x: i64, y: i64) -> Option<(usize, usize)> { + let (dx, dy) = (i128::from(x) - self.x0, i128::from(y) - self.y0); + if dx < 0 || dy < 0 || dx >= self.w || dy >= self.h { + return None; + } + let side = self.side as i128; + Some(((dx * side / self.w) as usize, (dy * side / self.h) as usize)) + } + + /// The wide closed paths, then those listed in the cell holding `(x, y)`. + fn at(&self, x: i64, y: i64) -> impl Iterator { + let cell = self.cell(x, y).and_then(|(cx, cy)| self.cells.get(cy * self.side + cx)).map_or(&[][..], Vec::as_slice); + self.wide.iter().chain(cell) + } +} + +/// Takes `n` from `work`; `None`, with `work` at 0, when it has less. +fn spend(work: &mut u64, n: u64) -> Option<()> { + let left = work.checked_sub(n); + *work = left.unwrap_or(0); + left.map(|_| ()) +} + +/// Does `p` start and end at one point, with three other points or more? +fn closed(p: &[(i64, i64)]) -> bool { + p.len() > 3 && p.first() == p.last() +} + +/// Twice the area the closed path `p` encloses. +fn twice_area(p: &[(i64, i64)]) -> i128 { + let sum: i128 = p + .windows(2) + .map(|w| match w { + [a, b] => i128::from(a.0) * i128::from(b.1) - i128::from(b.0) * i128::from(a.1), + _ => 0, + }) + .sum(); + sum.abs() +} + +/// Does `p` lie inside the closed path `ring`, as [`outer`] decides? `None` once `work` is spent. +fn inside(p: &[(i64, i64)], ring: &[(i64, i64)], work: &mut u64) -> Option { + let mut any = false; + for w in p.windows(2) { + let [s, e] = w else { continue }; + spend(work, ring.len() as u64)?; + // Where the segment meets the line without crossing it: its ends and the ring's points on + // it. Between two of these the segment is all inside or all outside. + let mut stops = vec![*s, *e]; + for edge in ring.windows(2) { + let [a, b] = edge else { continue }; + if crosses((*s, *e), (*a, *b)) { + return Some(false); + } + if a != s && a != e && on((*s, *e), *a) { + stops.push(*a); + } + } + let along = |q: &(i64, i64)| { + (i128::from(q.0) - i128::from(s.0)) * (i128::from(e.0) - i128::from(s.0)) + + (i128::from(q.1) - i128::from(s.1)) * (i128::from(e.1) - i128::from(s.1)) + }; + stops.sort_by_key(along); + stops.dedup(); + for part in stops.windows(2) { + let [m, n] = part else { continue }; + spend(work, ring.len() as u64)?; + // The part's middle, doubled so it is whole. + match locate((i128::from(m.0) + i128::from(n.0), i128::from(m.1) + i128::from(n.1)), ring) { + Some(false) => return Some(false), + Some(true) => any = true, + None => {} + } + } + } + Some(any) +} + +/// Which side of the line through `a` and `b` `c` is on: 1 left, -1 right, 0 on it. +fn side(a: (i64, i64), b: (i64, i64), c: (i64, i64)) -> i128 { + let d = (i128::from(b.0) - i128::from(a.0)) * (i128::from(c.1) - i128::from(a.1)) + - (i128::from(b.1) - i128::from(a.1)) * (i128::from(c.0) - i128::from(a.0)); + d.signum() +} + +/// Do segments `u` and `v` cross at a point inside both, each passing from one side of the other +/// to the other side? +fn crosses(u: ((i64, i64), (i64, i64)), v: ((i64, i64), (i64, i64))) -> bool { + side(u.0, u.1, v.0) * side(u.0, u.1, v.1) < 0 && side(v.0, v.1, u.0) * side(v.0, v.1, u.1) < 0 +} + +/// Does `q` lie on segment `u`? +fn on(u: ((i64, i64), (i64, i64)), q: (i64, i64)) -> bool { + let ((x0, y0), (x1, y1)) = u; + side(u.0, u.1, q) == 0 && q.0 >= x0.min(x1) && q.0 <= x0.max(x1) && q.1 >= y0.min(y1) && q.1 <= y0.max(y1) +} + +/// Where the doubled point `q` lies against the closed path `ring`: `Some(true)` inside by the +/// even-odd rule, `Some(false)` outside, `None` on its line. +fn locate(q: (i128, i128), ring: &[(i64, i64)]) -> Option { + let mut inside = false; + for w in ring.windows(2) { + let [a, b] = w else { continue }; + let (a, b) = ((2 * i128::from(a.0), 2 * i128::from(a.1)), (2 * i128::from(b.0), 2 * i128::from(b.1))); + // Which side of the segment's line `q` is on, 0 on it. + let side = (b.0 - a.0) * (q.1 - a.1) - (b.1 - a.1) * (q.0 - a.0); + if side == 0 && q.0 >= a.0.min(b.0) && q.0 <= a.0.max(b.0) && q.1 >= a.1.min(b.1) && q.1 <= a.1.max(b.1) { + return None; + } + // A segment crossing the level of `q` to its right. + if (a.1 > q.1) != (b.1 > q.1) && (side > 0) == (b.1 > a.1) { + inside = !inside; + } + } + Some(inside) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn square(x0: i64, y0: i64, x1: i64, y1: i64) -> Vec<(i64, i64)> { + vec![(x0, y0), (x1, y0), (x1, y1), (x0, y1), (x0, y0)] + } + + fn nest(paths: &[Vec<(i64, i64)>]) -> Option>> { + let boxes: Vec<[i64; 4]> = paths.iter().map(|p| bounds(p)).collect(); + let mut work = u64::MAX; + outer(paths, &boxes, &mut work) + } + + #[test] + fn points_inside_outside_and_on_the_line() { + let ring = square(0, 0, 10, 10); + assert_eq!(locate((10, 10), &ring), Some(true)); + assert_eq!(locate((30, 10), &ring), Some(false)); + assert_eq!(locate((10, -2), &ring), Some(false)); + assert_eq!(locate((20, 10), &ring), None); + assert_eq!(locate((0, 0), &ring), None); + } + + #[test] + fn a_path_inside_another_lies_inside_it_and_not_the_other_way() { + let paths = [square(0, 0, 100, 100), square(10, 10, 20, 20), vec![(30, 30), (40, 40)]]; + assert_eq!(nest(&paths), Some(vec![vec![], vec![0], vec![0]])); + } + + #[test] + fn touching_counts_as_inside_and_crossing_does_not() { + // A square with a corner on the outline's left edge, a triangle whose corners lie on the + // outline's edges, and a square that crosses the outline. + let paths = [square(0, 0, 100, 100), square(0, 40, 20, 60), vec![(0, 50), (50, 0), (100, 50), (0, 50)], square(90, 90, 120, 95)]; + assert_eq!(nest(&paths), Some(vec![vec![], vec![0], vec![0], vec![]])); + } + + /// Every point of a copy lies on the other's line, and none inside it. + #[test] + fn copies_of_one_outline_do_not_lie_inside_each_other() { + let paths = [square(0, 0, 10, 10), square(0, 0, 10, 10), vec![(10, 10), (10, 0), (0, 0), (0, 10), (10, 10)]]; + assert_eq!(nest(&paths), Some(vec![vec![], vec![], vec![]])); + } + + /// A line whose ends and middle lie inside an outline but which crosses a slot cut into it. + #[test] + fn a_line_across_a_slot_is_not_inside() { + let slotted = vec![(0, 0), (100, 0), (100, 100), (14, 100), (14, 40), (12, 40), (12, 100), (0, 100), (0, 0)]; + assert_eq!(nest(&[slotted, vec![(10, 50), (40, 50)]]), Some(vec![vec![], vec![]])); + } + + /// A line that leaves an outline and comes back through two of its corners, crossing none of + /// its sides, and one that runs along the bottom of a slot. + #[test] + fn a_line_through_corners_is_inside_only_where_it_stays_inside() { + let v_slot = vec![(0, 0), (100, 0), (100, 100), (20, 100), (20, 50), (15, 40), (10, 50), (10, 100), (0, 100), (0, 0)]; + assert_eq!(nest(&[v_slot, vec![(5, 50), (40, 50)]]), Some(vec![vec![], vec![]])); + let slot = vec![(0, 0), (100, 0), (100, 100), (20, 100), (20, 50), (10, 50), (10, 100), (0, 100), (0, 0)]; + assert_eq!(nest(&[slot, vec![(5, 50), (40, 50)]]), Some(vec![vec![], vec![0]])); + } + + /// An outline that crosses itself encloses no area by its signed sum, yet a line in one of its + /// lobes lies inside it. + #[test] + fn a_line_inside_a_lobe_of_a_crossed_outline_lies_inside_it() { + let bow = vec![(0, 0), (100, 100), (0, 100), (100, 0), (0, 0)]; + assert_eq!(twice_area(&bow), 0); + assert_eq!(nest(&[bow, vec![(40, 80), (60, 80)]]), Some(vec![vec![], vec![0]])); + } + + #[test] + fn a_ring_inside_a_ring_inside_a_ring_lies_inside_both() { + let paths = [square(20, 20, 80, 80), square(0, 0, 100, 100), square(40, 40, 60, 60)]; + assert_eq!(nest(&paths), Some(vec![vec![1], vec![], vec![0, 1]])); + } + + /// 10,000 parts in rows, each with a hole: each hole is tested against the few closed paths + /// near it, not against every part. + #[test] + fn parts_in_rows_are_tested_only_against_the_closed_paths_near_them() { + let mut paths = vec![]; + for row in 0..100 { + for col in 0..100 { + let (x, y) = (col * 30, row * 30); + paths.push(square(x, y, x + 20, y + 20)); + paths.push(square(x + 5, y + 5, x + 15, y + 15)); + } + } + let boxes: Vec<[i64; 4]> = paths.iter().map(|p| bounds(p)).collect(); + let mut work = 5_000_000; + let found = outer(&paths, &boxes, &mut work); + let want: Vec> = (0..paths.len()).map(|i| if i % 2 == 1 { vec![i - 1] } else { vec![] }).collect(); + assert_eq!(found, Some(want)); + } + + /// 400 closed paths that each cover most of the grid are listed once each, not in every cell + /// they cover. + #[test] + fn closed_paths_covering_many_cells_are_listed_once() { + let mut paths: Vec> = (0..400).map(|i| square(i, i, 1000 + i, 1000 + i)).collect(); + paths.extend((0..400).map(|i| { + let (x, y) = ((i % 20) * 50, (i / 20) * 50); + square(x, y, x + 10, y + 10) + })); + let boxes: Vec<[i64; 4]> = paths.iter().map(|p| bounds(p)).collect(); + let rings: Vec = (0..paths.len()).collect(); + let mut work = u64::MAX; + let Some(grid) = Grid::new(&rings, &boxes, &mut work) else { panic!("the budget ran out") }; + assert_eq!(grid.wide, (0..400).collect::>()); + assert!(grid.cells.iter().map(Vec::len).sum::() <= 400 * MAX_CELLS_PER_PATH); + } + + #[test] + fn finding_what_lies_inside_stops_at_its_budget() { + let paths: Vec> = (0..50).map(|i| square(-i, -i, 100 + i, 100 + i)).collect(); + let boxes: Vec<[i64; 4]> = paths.iter().map(|p| bounds(p)).collect(); + let mut work = 1_000; + assert_eq!(outer(&paths, &boxes, &mut work), None); + assert_eq!(work, 0); + let mut work = u64::MAX; + let found = outer(&paths, &boxes, &mut work); + assert_eq!(found.map(|o| o.iter().map(Vec::len).sum::()), Some(50 * 49 / 2)); + } +} diff --git a/crates/cad/src/hpgl/write/tests.rs b/crates/cad/src/hpgl/write/tests.rs index 2093fe8b48..6360cebdaf 100644 --- a/crates/cad/src/hpgl/write/tests.rs +++ b/crates/cad/src/hpgl/write/tests.rs @@ -819,6 +819,142 @@ fn sorting_stops_at_its_budget_and_keeps_every_path() { assert_eq!(text(&out).matches("PD").count(), 600); } +/// `x` points in plotter units. +fn plu(x: f64) -> i64 { + (x * 1016.0 / 72.0).round() as i64 +} + +/// The left edge of each path a plain HP-GL plot draws, in drawing order (plotter units). +fn lefts(out: &HpglOutput) -> Vec { + let mut lefts: Vec = vec![]; + for i in text(out).split_terminator(';') { + let xs = i.get(2..).unwrap_or_default().split(',').step_by(2).filter_map(|x| x.parse::().ok()); + match i.get(..2) { + Some("PU") => lefts.extend(xs.min()), + Some("PD") => { + if let (Some(l), Some(x)) = (lefts.last_mut(), xs.min()) { + *l = (*l).min(x); + } + } + _ => {} + } + } + lefts +} + +/// A letter O made as one path: its outline, then its counter. +#[test] +fn inside_first_cuts_a_letters_counter_before_its_outline() { + let d = doc_with(|d| { + let mut bp = rect(10.0, 10.0, 60.0, 90.0).to_bezpath(); + bp.extend(shapes::ellipse(Rect::new(25.0, 30.0, 45.0, 70.0)).to_bezpath()); + vec![filled(d, PathData::from_bezpath(&bp), Color::BLACK)] + }); + assert_eq!(lefts(&plot(&d, &opts(&d))), [plu(10.0), plu(25.0)]); + assert_eq!(lefts(&plot(&d, &HpglOptions { inside_first: true, ..opts(&d) })), [plu(25.0), plu(10.0)]); +} + +/// A part drawn before its two holes: the holes are cut first, nearest first with `sort`. +#[test] +fn a_part_with_two_holes_is_cut_holes_first() { + let d = doc_with(|d| { + vec![ + stroked(d, rect(10.0, 10.0, 190.0, 90.0), Color::BLACK), + stroked(d, shapes::ellipse(Rect::new(160.0, 40.0, 180.0, 60.0)), Color::BLACK), + stroked(d, shapes::ellipse(Rect::new(20.0, 40.0, 40.0, 60.0)), Color::BLACK), + ] + }); + let (part, right, left) = (plu(10.0), plu(160.0), plu(20.0)); + assert_eq!(lefts(&plot(&d, &opts(&d))), [part, right, left]); + assert_eq!(lefts(&plot(&d, &HpglOptions { inside_first: true, ..opts(&d) })), [right, left, part]); + // Sorting alone starts at the part's corner, nearest the origin; inside first, the nearest + // hole comes first, then the other, then the part. + assert_eq!(lefts(&plot(&d, &HpglOptions { sort: true, ..opts(&d) })), [part, left, right]); + let both = plot(&d, &HpglOptions { sort: true, inside_first: true, ..opts(&d) }); + assert_eq!(lefts(&both), [left, right, part]); + assert!(both.warnings.is_empty(), "{:?}", both.warnings); +} + +/// Rings inside rings are cut innermost first, and a ring with nothing inside it keeps its place +/// in paint order. +#[test] +fn nested_rings_are_cut_innermost_first() { + let d = doc_with(|d| { + vec![ + stroked(d, rect(120.0, 10.0, 190.0, 90.0), Color::BLACK), + stroked(d, rect(10.0, 10.0, 100.0, 90.0), Color::BLACK), + stroked(d, rect(20.0, 20.0, 90.0, 80.0), Color::BLACK), + stroked(d, rect(30.0, 30.0, 80.0, 70.0), Color::BLACK), + ] + }); + let out = plot(&d, &HpglOptions { inside_first: true, ..opts(&d) }); + assert_eq!(lefts(&out), [plu(120.0), plu(30.0), plu(20.0), plu(10.0)]); +} + +/// Each pen's paths are ordered among themselves: a red square inside a black part is drawn with +/// the red pen, after the black part. +#[test] +fn inside_first_orders_each_pens_paths() { + let d = doc_with(|d| { + vec![ + stroked(d, rect(10.0, 10.0, 190.0, 90.0), Color::BLACK), + stroked(d, rect(20.0, 20.0, 40.0, 40.0), Color::rgb8(255, 0, 0)), + stroked(d, rect(50.0, 20.0, 70.0, 40.0), Color::BLACK), + ] + }); + let out = plot(&d, &HpglOptions { inside_first: true, ..opts(&d) }); + assert_eq!(lefts(&out), [plu(50.0), plu(10.0), plu(20.0)]); +} + +/// Finding which of 100 nested rings lie inside which is past the budget: they are drawn in +/// paint order, with a warning. +#[test] +fn inside_first_past_its_budget_draws_in_paint_order() { + let d = doc_with(|d| (0..100).map(|i| stroked(d, rect(1.0 + 0.4 * f64::from(i), 10.0, 199.0, 90.0), Color::BLACK)).collect()); + let out = plot(&d, &HpglOptions { inside_first: true, ..opts(&d) }); + assert_eq!(out.warnings, [UNNESTED]); + let l = lefts(&out); + assert_eq!(l.len(), 100); + assert!(l.windows(2).all(|w| w[0] <= w[1]), "{l:?}"); + // With `sort` too, the budget is spent before sorting starts: paint order, with both warnings. + let out = plot(&d, &HpglOptions { sort: true, inside_first: true, ..opts(&d) }); + assert_eq!(out.warnings, [UNNESTED, UNSORTED]); + assert_eq!(lefts(&out), l); +} + +/// Sorting 600 lines inside two rings runs out of work: every path is drawn, the inner ring and +/// then the outer one last. +#[test] +fn inside_first_past_its_sorting_budget_keeps_the_rings_last() { + let d = doc_with(|d| { + let mut v = vec![stroked(d, rect(0.5, 5.0, 150.0, 30.0), Color::BLACK), stroked(d, rect(0.7, 7.0, 140.0, 25.0), Color::BLACK)]; + v.extend((0..600).map(|i| stroked(d, line((1.0 + (i % 100) as f64, 10.0), (1.0 + (i % 100) as f64, 20.0)), Color::BLACK))); + v + }); + let out = plot(&d, &HpglOptions { sort: true, inside_first: true, ..opts(&d) }); + assert_eq!(out.warnings, [UNSORTED_INSIDE]); + let l = lefts(&out); + assert_eq!(l.len(), 602); + assert_eq!(l.get(600..), Some(&[plu(0.7), plu(0.5)][..])); +} + +/// Without `sort`, 1,000 lines inside a ring are drawn in paint order and the ring after them, +/// within the budget. +#[test] +fn inside_first_alone_keeps_paint_order_for_many_paths() { + let d = doc_with(|d| { + let mut v = vec![stroked(d, rect(0.5, 5.0, 199.0, 30.0), Color::BLACK)]; + v.extend((0..1000).map(|i| stroked(d, line((1.0 + 0.19 * f64::from(i), 10.0), (1.0 + 0.19 * f64::from(i), 20.0)), Color::BLACK))); + v + }); + let out = plot(&d, &HpglOptions { inside_first: true, ..opts(&d) }); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + let l = lefts(&out); + assert_eq!(l.len(), 1001); + assert_eq!(l.last(), Some(&plu(0.5))); + assert!(l.get(..1000).is_some_and(|l| l.windows(2).all(|w| w[0] < w[1])), "{l:?}"); +} + #[test] fn art_wholly_past_the_plotter_range_is_cut_off_with_a_warning() { let d = doc_with(|d| vec![stroked(d, line((7e8, 50.0), (7e8 + 10.0, 50.0)), Color::BLACK)]); diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index b518478464..d56e327228 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -36,7 +36,7 @@ pub fn specs() -> Vec { "Export Document", [], None, - "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin., which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), …hpgl writes a plot of the artboard as pen moves for plotters and cutters (each visible path once along its line in plotter units; useArtboards: true one file per chosen artboard, false the bounds of the visible art), hpglVersion?: hpgl (default: IN, SP, PU, PD) | hpgl2 (also NP and PC pen colors, each pen's paths as one encoded polyline PE), curveTolerance?: 0.1 (points, 0.01 to 10), pens?: [\"#rrggbb\"…] (pen 1 first, up to 255; each path takes the nearest; default a pen per color in the order the art first draws in it, numbered alike in every file), origin?: bottomLeft (default) | center, sortPaths?: false (each pen's next path the nearest), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored. Never changes the document's path", + "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin., which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), …hpgl writes a plot of the artboard as pen moves for plotters and cutters (each visible path once along its line in plotter units; useArtboards: true one file per chosen artboard, false the bounds of the visible art), hpglVersion?: hpgl (default: IN, SP, PU, PD) | hpgl2 (also NP and PC pen colors, each pen's paths as one encoded polyline PE), curveTolerance?: 0.1 (points, 0.01 to 10), pens?: [\"#rrggbb\"…] (pen 1 first, up to 255; each path takes the nearest; default a pen per color in the order the art first draws in it, numbered alike in every file), origin?: bottomLeft (default) | center, sortPaths?: false (each pen's next path the nearest), insideFirst?: false (a path after the pen's paths that lie inside it), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored. Never changes the document's path", has_doc, export::export ), diff --git a/crates/engine/src/cmd/fileio/hpgl.rs b/crates/engine/src/cmd/fileio/hpgl.rs index 0016afa10e..154c9bce1d 100644 --- a/crates/engine/src/cmd/fileio/hpgl.rs +++ b/crates/engine/src/cmd/fileio/hpgl.rs @@ -48,6 +48,12 @@ pub(super) const OPTIONS: &[FormatOption] = &[ default: "false", description: "draw each pen's next path from the end nearest where the pen stopped, instead of in paint order", }, + FormatOption { + name: "insideFirst", + ty: "boolean", + default: "false", + description: "draw a path only after the pen's paths that lie inside it (a letter's counter before the letter, a hole before the part), the paths otherwise in paint order; with sortPaths, the nearest of the paths free to draw comes next", + }, ]; /// The HP-GL options in `p` (the region is set per plot). @@ -75,6 +81,7 @@ fn parse(p: &Value) -> Result { o.origin = Origin::from_id(s).ok_or_else(|| bad(C, format!("`{key}` must be one of {}", Origin::ALL.map(Origin::id).join(", "))))?; } o.sort = switch_or(p, "sortPaths", false, C)?; + o.inside_first = switch_or(p, "insideFirst", false, C)?; Ok(o) } diff --git a/crates/engine/src/cmd/fileio/tests_hpgl.rs b/crates/engine/src/cmd/fileio/tests_hpgl.rs index 1cb4c03b83..f4767fd81d 100644 --- a/crates/engine/src/cmd/fileio/tests_hpgl.rs +++ b/crates/engine/src/cmd/fileio/tests_hpgl.rs @@ -102,7 +102,7 @@ fn the_dialogs_and_the_format_list_name_hpgl() { let r = Session::new().execute("document.formats", &json!({})).unwrap(); let f = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "hpgl").unwrap().clone(); assert_eq!((f["read"].as_bool(), f["write"].as_bool(), f["raster"].as_bool()), (Some(true), Some(true), Some(false))); - for o in ["useArtboards", "hpglVersion", "curveTolerance", "pens", "origin", "sortPaths"] { + for o in ["useArtboards", "hpglVersion", "curveTolerance", "pens", "origin", "sortPaths", "insideFirst"] { assert!(f["options"].get(o).is_some(), "{o}: {f}"); } assert_eq!(writable_format(None, Some("x/part.plt")).unwrap().id, "hpgl"); @@ -184,6 +184,7 @@ fn export_options_are_checked() { (json!({"format": "hpgl", "pens": "#ff0000"}), "`pens` must be an array of 1 to 255 #rrggbb colors"), (json!({"format": "hpgl", "origin": "top"}), "`origin` must be one of bottomLeft, center"), (json!({"format": "hpgl", "sortPaths": "yes"}), "`sortPaths` must be true or false"), + (json!({"format": "hpgl", "insideFirst": 1}), "`insideFirst` must be true or false"), ]; for (p, want) in cases { let e = refused(&mut s, p.clone()); @@ -198,6 +199,25 @@ fn export_options_are_checked() { assert_eq!(plot, "IN;PU;SP2;PU-141,141;PD-564,141,-564,564,-141,564,-141,141;PU;SP0;"); } +/// `insideFirst` draws the square inside the other one first, though it was made last. +#[test] +fn inside_first_draws_the_inner_square_first() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s.execute("paint.setFill", &json!({"none": true})).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 80, "height": 80})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 30, "y": 30, "width": 20, "height": 20})).unwrap(); + let starts = |s: &mut Session, p: Value| { + let plot = String::from_utf8(bytes(&s.execute("document.export", &p).unwrap())).unwrap(); + (plot.find("PU141,1270;").unwrap(), plot.find("PU423,988;").unwrap()) + }; + let (outer, inner) = starts(&mut s, json!({"format": "hpgl"})); + assert!(outer < inner); + let (outer, inner) = starts(&mut s, json!({"format": "hpgl", "insideFirst": true})); + assert!(inner < outer); +} + /// Export Selection plots the selected square alone (the red one, made last), from the /// bottom-left corner of its bounds. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 4a73573b9b..ce29408fed 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6388,20 +6388,31 @@ else its topmost visible fill's, where a fill or stroke at 0% opacity does not c of its stops, a gradient mesh's the average of its points' colors (points at 0% opacity left out), and a pattern's its first visible solid fill (gray when it has none). The pens draw in turn, each pen's paths in paint order, or with `sortPaths: true` each next path the one that starts nearest where the pen stopped (an open path may be drawn from its other end, and a closed one -starts at its point nearest the pen). Curves become straight moves no further than `curveTolerance` points from the -curve (default 0.1, from 0.01 to 10). These defaults, the 255-pen limit and the rules for pen numbers and colors above -are VectorCraft's own. +starts at its point nearest the pen). With `insideFirst: true`, a path is drawn only after every path of the same +pen that lies inside it, for example a letter's counter before the letter and a part's holes before the part. A +path lies inside a closed path when it does not cross that path's line, every part of it lies inside that path (by +the even-odd rule) or on its line, and some part lies inside: a path touching the line around it is inside, a path +crossing it or leaving it through a corner is not, and copies of one outline are not inside each other. Without +`sortPaths` the paths otherwise keep paint order; with it, the next path is the nearest of those free to draw. +Curves become straight moves no further than `curveTolerance` points from the curve (default 0.1, from 0.01 to 10). +These defaults, the 255-pen limit and the rules for pen numbers, colors and the order of paths above are +VectorCraft's own. `hpglVersion: "hpgl"` (the default) writes `IN;`, then for each pen `PU;SP;` and each path `PUx,y;PDx,y,…;`, and ends with `PU;SP0;`. `"hpgl2"` also writes the palette (`NP` and a `PC` color for each pen) and draws each pen's paths as one encoded polyline (`PE`, base 64), which takes fewer bytes than the same moves as text. `warnings` names type drawn as outlines, inline graphics in type (left out), placed images, opacity masks, raster effects, stroke looks that are not drawn, art past the plotter's range, colors past 255 pens, and the limits on points, clipping -work, symbols, live art, nesting depth and sorting. +work, symbols, live art, nesting depth and sorting. Sorting and finding which paths lie inside which share one +work budget for the plot, 500 million box and point comparisons. When sorting runs out of it, the paths not yet +drawn keep paint order, each after the paths inside it with `insideFirst`. When it runs out while the writer is +finding which paths lie inside which, the paths of that pen and of the later pens keep paint order. Both cases add +a warning. ```json {"name":"export","arguments":{"path":"/tmp/decal.plt","format":"hpgl"}} {"name":"run_command","arguments":{"command":"document.export","params":{"format":"hpgl","path":"/tmp/sheet.plt","useArtboards":true,"hpglVersion":"hpgl2","pens":["#000000","#ff0000"],"sortPaths":true}}} +{"name":"run_command","arguments":{"command":"document.export","params":{"format":"hpgl","path":"/tmp/stencil.plt","sortPaths":true,"insideFirst":true}}} ``` ## TIFF, BMP and Targa export diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d9265079ea..e4fa67b0eb 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1508,6 +1508,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - HEIC and HEIF images open and place from `.heic` and `.heif`, as the [supported file formats](https://helpx.adobe.com/illustrator/kb/supported-file-formats-illustrator.html) list High Efficiency Image under both Open and Place (and [Fix HEIF file import errors on Windows](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-other-file-types/fix-heif-file-import-errors-on-windows.html) covers both ways of bringing HEIF files in): HEVC-coded single pictures and grids of tiles, 8 to 10 bits, with an alpha image, decoded by heic-rs in the engine's default `heif` feature. The container's rotation, mirror and crop are applied. Without an `nclx` box, the HEVC stream's video signal type gives the samples' matrix and range. An RGB ICC profile, or Display P3 with the sRGB curve named by an `nclx` box or, without a color box, by the stream, is converted to sRGB; the EXIF resolution tags size the image, or 72 ppi. Image sequences, other codings and grids of cropped tiles are refused with a message, and so is every HEIF file in a build without the feature. The decoder checks image size before allocating (`crates/engine/src/cmd/fileio/tests_heifread.rs`). - HP-GL/2 plot files (`.plt`, `.hpgl`, `.hgl`, `.hpg`, or a name no other format reads when the file starts with plotter instructions) open and place as paths on one layer, the artboard round the art: each line drawn with the pen down is a stroked path in its pen's color and width, line types become dash patterns, circles, arcs, wedges and Bézier curves are curves, filled and edged rectangles, wedges and polygons (polygon mode) are filled or stroked paths, and encoded polylines, scaling points, user units and rotation are read. Labels, symbol mode's symbols, raster images and hatching aren't drawn and are listed in the warnings (`crates/cad/src/hpgl/tests.rs`, `crates/engine/src/cmd/fileio/tests_hpgl.rs`). - Export As › HP-GL (`document.export {format: "hpgl"}`) writes a `.plt` plot of an artboard, each artboard or the art for pen plotters and cutters: each visible path once along its line in plotter units from the artboard's bottom-left corner or center, a pen per color or a pen table, curves flattened to a tolerance, type as outlines, clipping masks and the artboard's edges applied, and HP-GL or HP-GL/2 (pen colors and encoded polylines); plots read back by the HP-GL reader give the same lines (`crates/cad/src/hpgl/write/tests.rs`, `crates/engine/src/cmd/fileio/tests_hpgl.rs`). + - HP-GL export's `insideFirst` cuts each pen's paths inside before outside: a path is drawn only after the pen's paths that lie inside it (a letter's counter, a part's holes, rings within rings), touching outlines counted as inside and crossing ones not, in paint order or nearest first with `sortPaths`, within the plot's work budget; these order rules are VectorCraft's own (`crates/cad/src/hpgl/write/nest.rs`, `crates/cad/src/hpgl/write/tests.rs`, `crates/engine/src/cmd/fileio/tests_hpgl.rs`). - EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; grayscale TIFFs with an alpha channel or other extra channels place, relink and open, the alpha as the image's transparency (#1466 in part); PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546). - TIFFs that store each channel as its own plane (PlanarConfiguration 2) place, relink and open as the same image stored pixel by pixel does, 8 and 16 bits: grayscale with an alpha channel or other extra channels, and CMYK with or without them. A planar CMYK TIFF keeps its ink amounts and is stored as a CMYK TIFF with each pixel's inks together, at the file's resolution. A TIFF whose header claims more strips or tiles than can be counted is refused as too large to draw (#1466 in part; `crates/engine/src/cmd/fileio/tests.rs`). - 1-bit TIFFs compressed with CCITT Group 3 (ITU-T T.4) place, relink and open: Compression 2 (modified Huffman) and Compression 3 with one-dimensional rows or, with T4Options bit 0, two-dimensional ones, with or without fill bits, in strips or tiles, with either FillOrder and either polarity. Rows in T.4's uncompressed mode are not read. A file whose pixels would take more than the decoding allowance as RGBA is refused as too large to draw (#1466 in part; `crates/engine/src/cmd/fileio/tests.rs`).