Merge pull request #702 from storytold/pr/land-698-699

Land #698, #699: a saved multi-artboard .ai reopens as its document; FreeBSD hexagon test
This commit is contained in:
BFlat
2026-10-09 14:05:00 +07:00
committed by GitHub
3 changed files with 70 additions and 2 deletions
+31 -1
View File
@@ -185,7 +185,12 @@ fn polygon_rotation(corners: &[Point], step: f64) -> f64 {
})
.sum::<f64>()
};
[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<Point> = (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<Point> = (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);
+7 -1
View File
@@ -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<String>, 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),
@@ -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");
}