From a3081b9f538756afb073378f80d0a63e410bca10 Mon Sep 17 00:00:00 2001 From: EricKuck Date: Wed, 14 Oct 2026 14:13:20 -0400 Subject: [PATCH] AI import: a pattern fill tiles from its tile's lower left, wherever the definition drew the tile Co-Authored-By: Claude Opus 5.5 --- crates/eps/src/import/ai/read.rs | 14 ++++++++------ crates/eps/src/import/ai/tests.rs | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index b1b2379a5..ef19fd644 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -400,8 +400,8 @@ struct Reader<'a> { large_canvas: Option, /// This reader reads a pattern's definition (see [`Reader::define_pattern`]). pattern: Option, - /// The tile's top left (y down) of each pattern read, by name. - pattern_tiles: HashMap, + /// The tile (y down) of each pattern read, by name. + pattern_tiles: HashMap, /// The legacy text object being read (`To` … `TO`), and the modal type attributes. text: Option, type_state: legacy_text::TypeState, @@ -739,7 +739,7 @@ impl<'a> Reader<'a> { let art: Vec> = frame.children.iter().map(|n| Arc::new(self.doc.reid(n))).collect(); let mut def = PatternDef::new(&head.name, art); def.tile = FLIP.transform_rect_bbox(tile); - if self.pattern_tiles.insert(def.name.clone(), def.tile.origin()).is_some() { + if self.pattern_tiles.insert(def.name.clone(), def.tile).is_some() { self.doc.patterns.retain(|p| p.name != def.name); } self.doc.patterns.push(def); @@ -1411,13 +1411,15 @@ impl<'a> Reader<'a> { return; } let name = vals.iter().find_map(|v| if let V::Str(_) = v { v.text() } else { None }); - let Some(origin) = name.as_deref().and_then(|n| self.pattern_tiles.get(n)).map(|p| p.to_vec2()) else { + let Some(tile) = name.as_deref().and_then(|n| self.pattern_tiles.get(n)).copied() else { self.unreadable("pattern fills"); return; }; let matrix = vals.iter().rev().find_map(|v| if let V::Arr(a) = v { paint::affine(&Self::nums(a)) } else { None }); - // The tile's art (y down from the tile's top left) → the pattern's space → art space. - let xf = self.space() * matrix.unwrap_or(Affine::IDENTITY) * FLIP * Affine::translate(origin); + // The tile's art (y down from the tile's top left) → the pattern's space → art space. The + // tile's lower left is the pattern's origin wherever the definition drew it, as the file's + // PDF part tiles it. + let xf = self.space() * matrix.unwrap_or(Affine::IDENTITY) * FLIP * Affine::translate((0.0, -tile.height())); if xf.determinant().abs() < 1e-12 || !xf.as_coeffs().iter().all(|v| v.is_finite()) { self.unreadable("pattern fills"); return; diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 19ff88193..974f717d3 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -202,6 +202,21 @@ fn patterns_are_pattern_swatches_and_fill_with_them() { assert!(s.warnings.is_empty(), "{:?}", s.warnings); } +/// Where a definition draws its tile doesn't move the fill: the tile's lower left is the pattern's +/// origin, so the same dots drawn at 30..38 × 40..48 tile as they do at 0..8 × 0..8. +#[test] +fn a_pattern_tiles_from_its_lower_left_wherever_its_definition_drew_it() { + let moved = DOTS + .replace("(Dots) 0 0 8 8", "(Dots) 30 40 38 48") + .replace("%_0 8 m\n%_8 8 L\n%_8 0 L\n%_0 0 L\n%_0 8 L", "%_30 48 m\n%_38 48 L\n%_38 40 L\n%_30 40 L\n%_30 48 L") + .replace("%_4 4 m\n%_4 4 L", "%_34 44 m\n%_34 44 L"); + let body = format!("{moved}{}", layer(&format!("(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}f\n", rect_path(10.0, 20.0, 70.0, 80.0)))); + let s = read_ok(&body); + assert_eq!(s.doc.pattern("Dots").unwrap().tile, Rect::new(30.0, -48.0, 38.0, -40.0)); + let Paint::Pattern { xf, .. } = fill(&art(&s.doc)[0]) else { panic!() }; + assert_eq!(xf * Point::ZERO, Point::new(0.0, 92.0)); +} + /// A pattern whose definition it can't read whole isn't kept: a fill with it is something the file /// has that isn't read, where it shows. Its definition costs nothing else. #[test]