From 3e94f6ffd6a0af70319240cc5019b35a5b817fd8 Mon Sep 17 00:00:00 2001 From: callmeBalloch <12576795+callmeBalloch@users.noreply.github.com> Date: Fri, 9 Oct 2026 06:37:59 +0000 Subject: [PATCH 1/2] M2.9: hexagon orientation no longer depends on atan2 rounding The Shaper fits a hexagon's rotation by scoring 0, 90, 180 and 270 degrees, but a hexagon at 180 is the one at 0 (and 270 the one at 90): those pairs score the same up to rounding, so the platform's atan2 picked between them. FreeBSD's libm gave 270 where Linux gives 90, failing tilted_rectangles_ellipses_and_hexagons_snap_to_their_angle_steps on the FreeBSD build+test job. Candidates a whole number of periods from an earlier one are now dropped. A new test reproduces the flip on Linux with the old code (180 instead of 0 at tilt -9). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC --- crates/geom/src/recognize.rs | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) 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); From 1056a42a56e482b2177aea203532d86f0f9965ec Mon Sep 17 00:00:00 2001 From: llduyll10 Date: Fri, 9 Oct 2026 13:48:37 +0700 Subject: [PATCH 2/2] Open: a saved .ai with several artboards restores its document instead of asking for pages Opening a PDF-compatible .ai (or a PDF saved with Preserve Editing) that has more than one page showed the Import PDF dialog and imported pages, although the file carries its VectorCraft document. Opening now restores that document (every artboard and layer) without asking, as the engine already does when no pages are picked. Placing still asks for the page. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/import_pdf.rs | 8 ++++- .../ui-egui/src/dialogs/tests_import_pdf.rs | 32 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) 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"); +}