diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 8c6da8f73..9f7030621 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -4336,6 +4336,9 @@ proptest! { fn ai_blend_never_panics(steps in 1usize..6, cut in 0usize..2_500, edits in prop::collection::vec((0usize..2_500, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', ':', ';', '%', '_', 'K', 'S', 'A', 'B', 'u', 'U', 'F', 'X'])), 0..12)) { let text = mutate_text(&blend_text(steps), cut, &edits); let private = ai_private(&text); + // Far-out coordinates: the check's grid saturates instead of overflowing. + let far = ai_private(&blend_text(steps).replace(" 40 m\n", " 1e300 m\n").replace(" 50 L\n", " -1e300 L\n")); + survive("a .ai blend far out", || Some(vectorcraft_eps::layered_ai(&far, Document::new(100.0, 100.0), vec![], false, true, 1.0).0))?; survive("mutated .ai blend", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, true, 1.0).0))?; } } diff --git a/crates/eps/src/import/ai/blend.rs b/crates/eps/src/import/ai/blend.rs index 9cee1d81b..553ab64b8 100644 --- a/crates/eps/src/import/ai/blend.rs +++ b/crates/eps/src/import/ai/blend.rs @@ -202,9 +202,10 @@ fn same_fill(a: Fill, b: Fill) -> bool { } /// How far the objects `laid` are from the drawn ones (`drawn`: their centres and fills): the -/// farthest, each drawn object matched to a laid one within the tolerance (through a grid of -/// cells the tolerance wide, so each finds its neighbours without looking at them all), when they -/// are as many and each matches in place and fill; else `None`. +/// farthest, each drawn object matched to a laid one of its own within the tolerance (each laid +/// one matches one drawn one: the nearest still free; through a grid of cells the tolerance wide, +/// so each finds its neighbours without looking at them all), when they are as many and each +/// matches in place and fill; else `None` (also when that matching leaves one out). fn how_far(laid: &[Node], drawn: &[(Point, Fill)]) -> Option { if laid.len() != drawn.len() { return None; @@ -215,17 +216,23 @@ fn how_far(laid: &[Node], drawn: &[(Point, Fill)]) -> Option { for (i, (p, _)) in laid.iter().enumerate() { grid.entry(cell(*p)).or_default().push(i); } + let mut used = vec![false; laid.len()]; let mut worst: f64 = 0.0; for &(c, f) in drawn { let (cx, cy) = cell(c); - let near = (-1..=1) - .flat_map(|dx| (-1..=1).map(move |dy| (cx + dx, cy + dy))) + // Coordinates far out saturate the cells: the neighbours' keys saturate too. + let (i, near) = (-1i64..=1) + .flat_map(|dx| (-1i64..=1).map(move |dy| (cx.saturating_add(dx), cy.saturating_add(dy)))) .filter_map(|k| grid.get(&k)) .flatten() - .filter_map(|&i| laid.get(i)) - .filter(|(p, lf)| (*p - c).hypot() <= PLACE_TOLERANCE && same_fill(*lf, f)) - .map(|(p, _)| (*p - c).hypot()) - .min_by(f64::total_cmp)?; + .filter(|&&i| !used.get(i).copied().unwrap_or(true)) + .filter_map(|&i| laid.get(i).map(|l| (i, l))) + .filter(|(_, (p, lf))| (*p - c).hypot() <= PLACE_TOLERANCE && same_fill(*lf, f)) + .map(|(i, (p, _))| (i, (*p - c).hypot())) + .min_by(|a, b| a.1.total_cmp(&b.1))?; + if let Some(u) = used.get_mut(i) { + *u = true; + } worst = worst.max(near); } Some(worst) @@ -272,4 +279,21 @@ mod tests { assert!(live_blend(&edit, &drawn, &["A".into(), "B".into()], &Roles::new(), &mut budget).is_none()); assert_eq!(budget, 1); } + + /// Two drawn steps on one laid object don't both match it: each laid object matches one drawn + /// one, so a drawing the blend doesn't lay out isn't taken for it. + #[test] + fn each_laid_step_matches_one_drawn_step() { + let square = |x: f64| { + let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(x, 0.0, x + 10.0, 10.0)); + Node::path(NodeId(0), path, Default::default()) + }; + let laid = [square(0.0), square(50.0)]; + let c = |x: f64| (Point::new(x + 5.0, 5.0), Fill::None); + assert!(how_far(&laid, &[c(0.0), c(50.0)]).is_some()); + assert!(how_far(&laid, &[c(0.0), c(0.1)]).is_none(), "both drawn steps near the first laid one"); + // Coordinates far out don't overflow the grid. + let far = [square(1e300), square(-1e300)]; + let _ = how_far(&far, &[c(1e300), c(-1e300)]); + } } diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 1174356b0..b037ca2af 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -759,8 +759,11 @@ impl<'a> Reader<'a> { let line = line.trim_end(); if let Some((len, hex)) = self.blend_record.as_mut() { if !line.is_empty() && line.bytes().all(|b| b.is_ascii_hexdigit()) { - hex.extend_from_slice(line.as_bytes()); - if hex.len() >= *len * 2 { + // As many digits as the record says it has, no more. + let wanted = len.saturating_mul(2); + let take = wanted.saturating_sub(hex.len()).min(line.len()); + hex.extend_from_slice(line.as_bytes().get(..take).unwrap_or_default()); + if hex.len() >= wanted { self.end_blend(); } return Ok(()); diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 304d2a234..721365c25 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -1750,3 +1750,13 @@ fn moving_a_blends_key_moves_its_steps() { let after = middle(&keys); assert!((after - before - 20.0).abs() < 1.0, "the steps follow: {before} -> {after}"); } + +/// A record line longer than the record says is read only as far as its length: the digits after +/// it don't spoil it. +#[test] +fn a_blend_record_is_read_to_its_length() { + let record = blend_record(&['A', 'B']); + let longer = record.trim_end().to_string() + "DEADBEEF\n"; + let (blend, warnings) = read_blend(&blend_file("%_10 50 m\n%_110 50 L\n", [Some("A"), Some("B")], &longer)); + assert!(matches!(blend.kind, NodeKind::Blend { .. }), "{warnings:?}"); +}