diff --git a/crates/geom/src/recognize.rs b/crates/geom/src/recognize.rs index a149bfc7b..d27c4c9ee 100644 --- a/crates/geom/src/recognize.rs +++ b/crates/geom/src/recognize.rs @@ -185,7 +185,12 @@ fn polygon_rotation(corners: &[Point], step: f64) -> f64 { }) .sum::() }; - [0.0, 90.0, 180.0, 270.0].into_iter().filter(|r| r % step == 0.0).min_by(|a, b| error(*a).total_cmp(&error(*b))).unwrap_or(0.0) + // A candidate a whole number of periods from an earlier one is the same polygon (a hexagon at 270° + // is the one at 90°) and scores the same up to rounding, so it is dropped: otherwise the platform's + // `atan2` rounding picks between them (FreeBSD's libm gave 270° where Linux gives 90°). + let candidates = [0.0, 90.0, 180.0, 270.0].into_iter().filter(|r| r % step == 0.0); + let distinct = candidates.clone().enumerate().filter(|&(i, r)| !candidates.clone().take(i).any(|q| (r - q) % period == 0.0)); + distinct.map(|(_, r)| r).min_by(|a, b| error(*a).total_cmp(&error(*b))).unwrap_or(0.0) } fn rectangle_rotation(corners: &[Point]) -> f64 { @@ -533,6 +538,31 @@ mod tests { } } + #[test] + fn hexagon_rotation_never_depends_on_rounding_between_equivalent_angles() { + // 0° and 180° (and 90° and 270°) are the same hexagon, so only 0° and 90° may come back, whatever + // rounding does to the tied scores: on FreeBSD the tilted test above got 270°. + for direction in [0.0_f64, 90.0] { + for tilt in [-9.0_f64, -7.0, -3.0, 3.0, 7.0, 9.0] { + for offset in [0.0, 1e-7, 0.1, 1e3, 1e6] { + let corners: Vec = (0..6) + .map(|i| { + let a = (direction + tilt - 90.0).to_radians() + std::f64::consts::TAU * i as f64 / 6.0; + Point::new(offset + 70.0 * a.cos(), offset + 70.0 * a.sin()) + }) + .collect(); + assert_eq!(polygon_rotation(&corners, 90.0), direction, "tilt {tilt}, offset {offset}"); + let mut shifted = corners.clone(); + shifted.rotate_left(3); + assert_eq!(polygon_rotation(&shifted, 90.0), direction, "tilt {tilt}, offset {offset}, from the opposite corner"); + } + } + } + // Triangles have no such twin among the candidates: 0° and 180° stay distinct. + let down: Vec = (0..3).map(|i| Point::new(0.0, 0.0) + Vec2::from_angle((90.0_f64 + 120.0 * i as f64).to_radians()) * 50.0).collect(); + assert_eq!(polygon_rotation(&down, 180.0), 180.0); + } + #[test] fn square_fit_ignores_sampling_density_and_document_offset() { let mut stroke = wobbly(&[Point::new(0.0, 0.0), Point::new(100.0, 8.0), Point::new(94.0, 100.0), Point::new(10.0, 91.0)], 24, 4.0); diff --git a/crates/ui-egui/src/dialogs/import_pdf.rs b/crates/ui-egui/src/dialogs/import_pdf.rs index d6328fce9..c485a3d89 100644 --- a/crates/ui-egui/src/dialogs/import_pdf.rs +++ b/crates/ui-egui/src/dialogs/import_pdf.rs @@ -114,11 +114,17 @@ pub(super) fn show_preview(ui: &mut egui::Ui, tex: Option<&egui::TextureHandle>, /// Open `bytes` (or place them with the `file.place` params `place`) through the dialog when the /// file is a PDF (or `.ai`/`.ait`) with several pages or a password; false when there's nothing to -/// ask (the caller goes on). +/// ask (the caller goes on), including a file that carries its VectorCraft document when opening. pub fn offer(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option, place: Option<&Value>) -> bool { if !fileio::detect(name, bytes).is_some_and(|f| matches!(f.id, "pdf" | "ai" | "ait")) { return false; } + // A file carrying its VectorCraft document (a saved .ai, or a PDF saved with Preserve Editing) + // opens as that document, every artboard and layer: there are no pages to pick. Placing still + // asks, since a place takes one page. + if place.is_none() && vectorcraft_pdf::editing_with(bytes, None).is_some() { + return false; + } let (pages, locked) = match vectorcraft_pdf::info(bytes, None) { Ok(i) if i.pages.len() > 1 => (i.pages.len(), false), Err(PdfError::NeedsPassword) => (0, true), diff --git a/crates/ui-egui/src/dialogs/tests_import_pdf.rs b/crates/ui-egui/src/dialogs/tests_import_pdf.rs index 03d1aa8ee..883014739 100644 --- a/crates/ui-egui/src/dialogs/tests_import_pdf.rs +++ b/crates/ui-egui/src/dialogs/tests_import_pdf.rs @@ -165,3 +165,35 @@ fn placing_a_pdf_page_asks_for_the_page_and_crop_box() { assert!(app.ui.dialog.is_none()); assert_eq!(placed(&app).1.width(), 80.0); } + +/// A saved `.ai` with several artboards carries its VectorCraft document: opening it restores that +/// document (artboards, layers) instead of asking which pages to import. +#[test] +fn opening_a_saved_ai_with_several_artboards_restores_it_without_asking() { + let mut app = app(); + app.run("artboard.new", json!({"width": 200, "height": 100, "name": "Second"})).unwrap(); + app.run("layer.new", json!({"name": "Front"})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap(); + let saved = app.run("document.save", json!({"format": "ai"})).unwrap(); + let bytes = vectorcraft_format::base64_decode(saved["dataBase64"].as_str().unwrap()).unwrap(); + assert!(vectorcraft_pdf::info(&bytes, None).unwrap().pages.len() > 1, "a page per artboard"); + + io::open_bytes(&mut app, "two.ai", &bytes, Some("/tmp/two.ai".into())).unwrap(); + assert!(app.ui.dialog.is_none(), "no Import PDF dialog"); + assert_eq!(app.session.documents().len(), 2); + let doc = &app.session.active().unwrap().doc; + assert_eq!(doc.artboards.len(), 2); + assert_eq!(doc.artboards[1].name, "Second"); + assert!(doc.layers.iter().any(|l| l.name.as_deref() == Some("Front")), "layers come back"); +} + +/// A PDF without a VectorCraft document still asks, and placing a saved `.ai` still asks for the page. +#[test] +fn placing_a_saved_ai_still_asks_for_the_page() { + let mut app = app(); + app.run("artboard.new", json!({"width": 200, "height": 100})).unwrap(); + let saved = app.run("document.save", json!({"format": "ai"})).unwrap(); + let bytes = vectorcraft_format::base64_decode(saved["dataBase64"].as_str().unwrap()).unwrap(); + assert!(import_pdf::offer(&mut app, "two.ai", &bytes, None, Some(&json!({})))); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("mode"), "place"); +}