diff --git a/Cargo.lock b/Cargo.lock index ceb90d1289..bd99b2f64c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4348,6 +4348,7 @@ dependencies = [ "gethostname", "gltf", "log", + "png", "quick-xml", "rustix 1.1.5", "serde", @@ -4357,6 +4358,7 @@ dependencies = [ "solidcraft-geom", "solidcraft-kernel", "thiserror 2.0.21", + "tiny-skia", "zip", ] diff --git a/crates/engine/src/cmd/drawing_export.rs b/crates/engine/src/cmd/drawing_export.rs index 67d3fdc96f..8c16d0c3ae 100644 --- a/crates/engine/src/cmd/drawing_export.rs +++ b/crates/engine/src/cmd/drawing_export.rs @@ -6,7 +6,9 @@ use std::path::Path; use serde_json::Value; +use solidcraft_doc::drawing::DEFAULT_DPI; use solidcraft_doc::ids; +use solidcraft_geom::paper::NoPictures; use super::CommandSpec; use crate::drawing_scene::{Status, sheet_id}; @@ -58,7 +60,8 @@ fn export(s: &mut Session, p: &Value) -> Result { return Err(bad(CMD, format!("not written: views on the sheet failed: {}", failed.join(", ")))); } let prepared = built.scene.prepare().map_err(|e| bad(CMD, e.to_string()))?; - let (svg, written) = solidcraft_io::paper::paper_svg(&prepared).map_err(|e| bad(CMD, e.to_string()))?; + // A drawing's scene holds no pictures yet, so their resolution goes unused until shaded views bring the sheet's own. + let (svg, written) = solidcraft_io::paper::paper_svg(&prepared, &NoPictures, DEFAULT_DPI).map_err(|e| bad(CMD, e.to_string()))?; let bytes = svg.len(); let (path, svg) = match request.path { Some(path) => { diff --git a/crates/engine/src/cmd/drawing_export_tests.rs b/crates/engine/src/cmd/drawing_export_tests.rs index 8871e4f49a..d9f86d5517 100644 --- a/crates/engine/src/cmd/drawing_export_tests.rs +++ b/crates/engine/src/cmd/drawing_export_tests.rs @@ -19,7 +19,7 @@ fn refused(s: &mut Session, p: Value) -> String { fn scene_svg(s: &Session, sheet: u64) -> Vec { let d = s.drawing("test").unwrap().clone(); let built = crate::drawing_scene::build(s, &d, sheet).unwrap(); - solidcraft_io::paper::paper_svg(&built.scene.prepare().unwrap()).unwrap().0 + solidcraft_io::paper::paper_svg(&built.scene.prepare().unwrap(), &NoPictures, DEFAULT_DPI).unwrap().0 } /// A sheet exports as its scene's SVG, byte for byte, by id or by name: the reply holds it, with diff --git a/crates/engine/src/cmd/drawing_view.rs b/crates/engine/src/cmd/drawing_view.rs index ad290f6d29..bbca726804 100644 --- a/crates/engine/src/cmd/drawing_view.rs +++ b/crates/engine/src/cmd/drawing_view.rs @@ -29,9 +29,10 @@ use solidcraft_geom::Plane; use solidcraft_geom::drawing_sheet::{DrawingBorderProfile, Sheet}; use solidcraft_geom::drawing_view::{NamedView, PRESETS, Scale}; use solidcraft_geom::hlr::{Source, Visibility}; -use solidcraft_geom::paper::MAX_COORDINATE_MM; +use solidcraft_geom::paper::{MAX_COORDINATE_MM, Picture}; use solidcraft_geom::section::CurveAccuracy; use solidcraft_geom::{Aabb2, Vec2}; +use solidcraft_io::paper::MAX_PICTURE_DPI; use solidcraft_render::VisualStyle; use super::CommandSpec; @@ -39,8 +40,8 @@ use crate::drawing_view::{CHORD_RANGE, Extents, LineRequest, Lines, MAX_PATH_IDS use crate::params::bad; use crate::schema::drawing_view::{ AccuracyResult, BoxResult, ChoicesResult, CopyParams, DPI_RANGE, EditParams, EditResult, ExtentsResult, FromResult, InspectParams, InspectResult, - LineResult, LineSourceResult, LinesStatusResult, MoveParams, OutsideResult, PathSegResult, PieceResult, PlaceParam, ScaleResult, SceneResult, - SettingResult, SettingsResult, SnapshotParams, SnapshotResult, SourceResult, ToSheetResult, ViewResult, ViewSet, + LineResult, LineSourceResult, LinesStatusResult, MoveParams, OutsideResult, PathSegResult, PictureResult, PieceResult, PlaceParam, ScaleResult, + SceneResult, SettingResult, SettingsResult, SnapshotParams, SnapshotResult, SourceResult, ToSheetResult, ViewResult, ViewSet, }; use crate::schema::{TypedContract, output_schema_for}; use crate::{Result, Session}; @@ -61,7 +62,7 @@ pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new(CMD, "Drawing View Snap .noundo() .contract(&CONTRACT) .output(output_schema_for::) - .params("view?: front (default), back, top, bottom, left, right, iso (= iso_front_right), iso_front_left, iso_back_right, iso_back_left, or {direction, up, origin?}; scale?: \"paper:model\" such as \"1:2\" (default: the largest ISO 5455 scale that fits the sheet's drawing area); position_mm?: [x, y] for the view's centre on the sheet (default the sheet's centre); sheet?: {size: {standard: a3} or {custom: {sides_mm: [w, h]}}, orientation?: landscape|portrait, dpi?: 25..2400} (default A3 landscape, 300 dpi); display_style?, reference_style?: visible (default) or visible_hidden (the shaded styles aren't supported yet); reference_hides_model?: false; reference_margin_mm?: 0..100000 model mm on every side; tangent_edges?: hide (default) or show; chord_mm?: 0.001..1 paper mm the lines are sampled at (default 0.01); max_error_mm?: the accuracy the lines must certify on paper (refused while they come from the mesh); scene?: true adds the lines as a paper scene of the sheet (visible lines only for now); source?: {occurrences: [[ids…]]}, the occurrence subtrees drawn ([] the design's own bodies; default every body); hide?: [[ids…]], subtrees left out; at most 1000 paths in each and 4096 ids in all. Every body the design places, whatever the browser shows. Returns {view, scale: {text, factor, fitted}, position_mm, sheet, extents (model mm on the view's plane), boundary_mm (on the sheet), lines: [{occurrence, body, reference, source: {kind: edge, edge, tangent} or {kind: silhouette, face}, pieces: [{visibility, path: [paper mm lines, and arcs for circles facing the view], accuracy}]}], status: {lines: approximate, provenance: mesh_projection, chord_mm, model_chord_mm, uncertain_pieces, covered_pieces}, stats, scene (with scene: true: {size_mm, groups: [{object: 0, kind: view, items: [{kind: stroke, path, closed, width_mm}]}]}), source}. Read-only: saves nothing."), + .params("view?: front (default), back, top, bottom, left, right, iso (= iso_front_right), iso_front_left, iso_back_right, iso_back_left, or {direction, up, origin?}; scale?: \"paper:model\" such as \"1:2\" (default: the largest ISO 5455 scale that fits the sheet's drawing area); position_mm?: [x, y] for the view's centre on the sheet (default the sheet's centre); sheet?: {size: {standard: a3} or {custom: {sides_mm: [w, h]}}, orientation?: landscape|portrait, dpi?: 25..2400} (default A3 landscape, 300 dpi); display_style?, reference_style?: visible (default), visible_hidden or shaded (drawn as a picture, without its own lines, and not with scene; shaded_visible and shaded_visible_hidden aren't supported yet); reference_hides_model?: false; reference_margin_mm?: 0..100000 model mm on every side; tangent_edges?: hide (default) or show; chord_mm?: 0.001..1 paper mm the lines are sampled at (default 0.01); max_error_mm?: the accuracy the lines must certify on paper (refused while they come from the mesh); scene?: true adds the lines as a paper scene of the sheet (visible lines only for now); source?: {occurrences: [[ids…]]}, the occurrence subtrees drawn ([] the design's own bodies; default every body); hide?: [[ids…]], subtrees left out; at most 1000 paths in each and 4096 ids in all. Every body the design places, whatever the browser shows. Returns {view, scale: {text, factor, fitted}, position_mm, sheet, extents (model mm on the view's plane), boundary_mm (on the sheet), lines: [{occurrence, body, reference, source: {kind: edge, edge, tangent} or {kind: silhouette, face}, pieces: [{visibility, path: [paper mm lines, and arcs for circles facing the view], accuracy}]}], status: {lines: approximate, provenance: mesh_projection, chord_mm, model_chord_mm, uncertain_pieces, covered_pieces}, stats, scene (with scene: true: {size_mm, groups: [{object: 0, kind: view, items: [{kind: stroke, path, closed, width_mm}]}]}), picture? ({pixels: [w, h], dpi, rect_mm} when something is shaded: the boundary's pixel grid at sheet.dpi or 1200, the lower, which dpi gives), source}. Read-only: saves nothing."), CommandSpec::new(INSPECT, "Inspect Drawing View", crate::checked!("drawing.view.inspect", inspect)) .icon("inspect") .in_drawing() @@ -141,7 +142,10 @@ fn snapshot(s: &mut Session, p: &Value) -> Result { tangent_edges: settings.tangent_edges == TangentEdges::Show, chord_mm, }; - let drawn = crate::drawing_view::lines(s, &set, &frame, &asked, CMD)?; + let mut drawn = crate::drawing_view::lines(s, &set, &frame, &asked, CMD)?; + // A shaded part is drawn by its picture, so its own lines are left out; it still hides what + // lies behind it. + drawn.lines.retain(|l| !(if l.reference { settings.reference_style } else { settings.display_style }).is_shaded()); if let Some(max) = request.max_error_mm { certify(&drawn, max, scale)?; } @@ -152,6 +156,9 @@ fn snapshot(s: &mut Session, p: &Value) -> Result { } else { None }; + let flags: Vec = set.bodies.iter().map(|b| b.reference).collect(); + let pictures = crate::drawing_picture::pictures(settings.display_style, settings.reference_style, settings.reference_hides_model, &flags); + let picture = if pictures.is_empty() { None } else { Some(picture(boundary, request.sheet.dpi)?) }; let named = match settings.view { ViewDirection::Named(view) => Some(snake_name(view)), @@ -184,6 +191,7 @@ fn snapshot(s: &mut Session, p: &Value) -> Result { }, stats: drawn.stats.into(), scene, + picture, source: SourceResult { bodies: set.bodies.len(), timeline_at_end: s.doc.marker.is_none() }, param_warnings: Vec::new(), }; @@ -645,6 +653,15 @@ fn fit(size: Vec2, position: Vec2, border: Aabb2) -> Result { }) } +/// The picture of a view whose boundary is `rect`, on its pixel grid at the sheet's `dpi` or the +/// most a picture is drawn at, the lower (lines may export finer); refused past the grid's side +/// limit. +fn picture(rect: Aabb2, dpi: f64) -> Result { + let dpi = dpi.min(MAX_PICTURE_DPI); + let grid = Picture { source: 0, rect, opacity: 1.0 }.grid(dpi).map_err(|e| bad(CMD, e.to_string()))?; + Ok(PictureResult { pixels: [grid.width, grid.height], dpi, rect_mm: BoxResult { min: rect.min.into(), max: rect.max.into() } }) +} + /// A unit enum's name as it reads and writes (`iso_front_right`, `visible_hidden`). fn snake_name(value: impl Serialize) -> String { serde_json::to_value(value).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default() diff --git a/crates/engine/src/cmd/drawing_view_tests.rs b/crates/engine/src/cmd/drawing_view_tests.rs index 0fc774fa3e..981af4c5b8 100644 --- a/crates/engine/src/cmd/drawing_view_tests.rs +++ b/crates/engine/src/cmd/drawing_view_tests.rs @@ -2,8 +2,10 @@ use std::sync::Arc; use serde_json::json; use solidcraft_doc::MAX_PLACEMENT_DEPTH; +use solidcraft_doc::drawing::DEFAULT_DPI; use solidcraft_geom::cell::Projection; use solidcraft_geom::drawing_view::ProjectedSide; +use solidcraft_geom::paper::NoPictures; use super::*; use crate::schema::tests::conforms; @@ -444,9 +446,64 @@ fn scale_position_and_margin_set_the_boundary_and_the_design_is_untouched() { assert_eq!(snap(&mut s, json!({"view": "top"}))["boundary_mm"], first["boundary_mm"], "a hidden body is still drawn"); } -/// What the snapshot refuses, each with nothing changed: bad settings, the shaded styles until -/// the drawing renderer, the sheet's resolution out of range, an empty design, a position off -/// the sheet's limit, and a view that would reach past the paper's coordinates. +/// A shaded view has a picture: the block's front view at 1:2 is 50 x 10 mm, 591 x 119 px at +/// 300 dpi (rounded outward) and 2363 x 473 at 1200, and at 1200 too on a 2400 dpi sheet (lines may +/// export finer, a picture is drawn at most at 1200); a view with nothing shaded in it has none. A +/// shaded part's own lines are left out, its picture draws it: the block's, a model part's, when +/// the model is shaded, not when only reference parts are. +#[test] +fn a_shaded_view_has_the_picture_of_its_boundary() { + let mut s = block(); + let reply = snap(&mut s, json!({"display_style": "shaded", "scale": "1:2"})); + let schema = output_schema_for::(); + assert!(conforms(schema.as_value(), schema.as_value(), &reply), "{reply}"); + assert_eq!(reply["lines"], json!([]), "the block is shaded: its picture draws it"); + assert_eq!(reply["picture"]["pixels"], json!([591, 119])); + assert_eq!(reply["picture"]["dpi"], 300.0); + assert_eq!(reply["picture"]["rect_mm"], reply["boundary_mm"]); + let fine = snap(&mut s, json!({"display_style": "shaded", "scale": "1:2", "sheet": {"dpi": 1200}})); + assert_eq!(fine["picture"]["pixels"], json!([2363, 473])); + let finest = snap(&mut s, json!({"display_style": "shaded", "scale": "1:2", "sheet": {"dpi": 2400}})); + assert_eq!( + (&finest["picture"]["pixels"], &finest["picture"]["dpi"], &finest["sheet"]["dpi"]), + (&json!([2363, 473]), &json!(1200.0), &json!(2400.0)) + ); + assert!(snap(&mut s, json!({"scale": "1:2"})).get("picture").is_none(), "lines only"); + let none = snap(&mut s, json!({"reference_style": "shaded", "scale": "1:2"})); + assert!(none.get("picture").is_none(), "shaded reference parts, and none in the design: {none}"); + assert_eq!(none["lines"], snap(&mut s, json!({"scale": "1:2"}))["lines"], "the block's lines, as a model part's"); +} + +/// A shaded part's lines are left out and the other style's parts keep theirs: a plate, and a hinge +/// holding a pin placed twice, the hinge a reference component. A shaded model leaves only the +/// pins' lines; shaded reference parts leave only the plate's; between them, every body's. +#[test] +fn a_shaded_part_leaves_out_only_its_own_lines() { + let mut s = Session::default(); + run(&mut s, "component.create", json!({"name": "Plate"})); + run(&mut s, "solid.box", json!({"length": 40, "width": 20, "height": 3})); + run(&mut s, "component.activate", json!({"component": 0})); + run(&mut s, "component.create", json!({"name": "Hinge", "activate": false})); + run(&mut s, "component.create", json!({"name": "Pin", "parent": "Hinge"})); + run(&mut s, "solid.cylinder", json!({"diameter": 4, "height": 30})); + run(&mut s, "component.activate", json!({"component": 0})); + run(&mut s, "occurrence.copy", json!({"component": "Hinge", "translate": [0, 50, 0]})); + run(&mut s, "component.metadata.set", json!({"component": "Hinge", "bom_type": "reference"})); + let references = |reply: &Value| -> Vec { reply["lines"].as_array().unwrap().iter().map(|l| l["reference"].as_bool().unwrap()).collect() }; + let bodies = |reply: &Value| -> std::collections::BTreeSet { + reply["lines"].as_array().unwrap().iter().map(|l| l["body"].as_str().unwrap().to_owned()).collect() + }; + let all = snap(&mut s, json!({})); + let model_shaded = snap(&mut s, json!({"display_style": "shaded"})); + let references_shaded = snap(&mut s, json!({"reference_style": "shaded"})); + assert!(!references(&model_shaded).is_empty() && references(&model_shaded).iter().all(|r| *r), "{model_shaded}"); + assert!(!references(&references_shaded).is_empty() && references(&references_shaded).iter().all(|r| !*r), "{references_shaded}"); + assert_eq!(bodies(&model_shaded).union(&bodies(&references_shaded)).cloned().collect::>(), bodies(&all)); +} + +/// What the snapshot refuses, each with nothing changed: bad settings, the shaded styles with +/// lines until the view's exact lines, the sheet's resolution out of range, an empty design, a +/// position off the sheet's limit, and a view that would reach past the paper's coordinates. #[test] fn hostile_snapshots_are_refused() { let mut s = block(); @@ -462,8 +519,9 @@ fn hostile_snapshots_are_refused() { (json!({"view": {"direction": [0, 0, 0], "up": [0, 0, 1]}}), "direction"), (json!({"view": {"direction": [1e308, 1e308, 0], "up": [0, 0, 1]}}), "direction"), (json!({"view": {"direction": [0, 0, 1], "up": [0, 0, 2]}}), "along the view direction"), - (json!({"display_style": "shaded"}), "not supported yet"), - (json!({"reference_style": "shaded_visible"}), "not supported yet"), + (json!({"display_style": "shaded_visible_hidden"}), "not supported yet"), + (json!({"reference_style": "shaded_visible"}), "not supported yet: shaded views with lines need the view's exact lines (W06)"), + (json!({"display_style": "shaded", "scene": true}), "not supported yet: shaded views need the drawing renderer (#5624)"), (json!({"display_style": "Shaded"}), "unknown variant"), (json!({"tangent_edges": "thin"}), "thin"), (json!({"sheet": {"dpi": 24.9}}), "dpi"), @@ -855,7 +913,7 @@ fn strokes(v: &Value) -> Vec<(Vec, bool, f64)> { items .filter_map(|item| match item { Item::Stroke { path, closed, width_mm } => Some((path, closed, width_mm)), - Item::Fill { .. } | Item::Text(_) => None, + Item::Fill { .. } | Item::Text(_) | Item::Picture(_) => None, }) .collect() } @@ -878,7 +936,7 @@ fn a_views_scene_is_its_visible_lines_as_strokes() { let length: f64 = strokes.iter().flat_map(|(path, _, _)| path).map(|seg| seg.start().dist(seg.end())).sum(); assert!((length - 120.0).abs() < 1e-6, "{v}"); let scene: solidcraft_geom::paper::Scene = serde_json::from_value(v["scene"].clone()).unwrap(); - let (svg, _) = solidcraft_io::paper::paper_svg(&scene.prepare().unwrap()).unwrap(); + let (svg, _) = solidcraft_io::paper::paper_svg(&scene.prepare().unwrap(), &NoPictures, DEFAULT_DPI).unwrap(); let svg = String::from_utf8(svg).unwrap(); let counts = (svg.matches("").count(), svg.matches(" Vec> { + let (model, references) = (display.is_shaded(), reference.is_shaded()); + let picture = |part: fn(bool, bool) -> Part| is_reference.iter().map(|r| part(*r, hides_model)).collect::>(); + let pictures = match (model, references) { + (false, false) => vec![], + // Each hides the other: one picture. Otherwise the references' own, under the model's. + (true, true) if hides_model => vec![picture(|_, _| Part::Painted)], + (true, true) => vec![picture(|r, _| if r { Part::Painted } else { Part::Left }), picture(|r, _| if r { Part::Left } else { Part::Painted })], + // Lines-only reference parts hide the model's picture only when they hide the model. + (true, false) => vec![picture(|r, hides| match (r, hides) { + (false, _) => Part::Painted, + (true, true) => Part::DepthOnly, + (true, false) => Part::Left, + })], + // A lines-only model still hides the reference parts behind it. + (false, true) => vec![picture(|r, _| if r { Part::Painted } else { Part::DepthOnly })], + }; + pictures.into_iter().filter(|p| p.contains(&Part::Painted)).collect() +} + +#[cfg(test)] +#[path = "drawing_picture_tests.rs"] +mod tests; diff --git a/crates/engine/src/drawing_picture_tests.rs b/crates/engine/src/drawing_picture_tests.rs new file mode 100644 index 0000000000..4d7c16b466 --- /dev/null +++ b/crates/engine/src/drawing_picture_tests.rs @@ -0,0 +1,66 @@ +use serde_json::{Value, json}; +use solidcraft_render::VisualStyle::{Shaded, Visible, VisibleHidden}; + +use super::*; +use crate::Session; + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + match s.execute(id, &p) { + Ok(v) => v, + Err(e) => panic!("{id} {p}: {e}"), + } +} + +/// Each case of the two styles and the hide setting, for a model body and a reference part: +/// the pictures bottom first, as (model body, reference part). +#[test] +fn each_style_and_hide_setting_gives_its_pictures() { + use Part::{DepthOnly, Left, Painted}; + let both = [false, true]; + let cases = [ + (Visible, VisibleHidden, false, vec![]), + (Visible, Visible, true, vec![]), + (Shaded, Shaded, true, vec![(Painted, Painted)]), + (Shaded, Shaded, false, vec![(Left, Painted), (Painted, Left)]), + (Shaded, Visible, true, vec![(Painted, DepthOnly)]), + (Shaded, VisibleHidden, false, vec![(Painted, Left)]), + (Visible, Shaded, false, vec![(DepthOnly, Painted)]), + (VisibleHidden, Shaded, true, vec![(DepthOnly, Painted)]), + ]; + for (display, reference, hides, expected) in cases { + let got: Vec<(Part, Part)> = pictures(display, reference, hides, &both).into_iter().map(|p| (p[0], p[1])).collect(); + assert_eq!(got, expected, "{display:?} {reference:?} hides {hides}"); + } + // A picture with nothing painted in it is left out: shaded references, none there. + assert_eq!(pictures(Visible, Shaded, false, &[false, false]), Vec::>::new()); + assert_eq!(pictures(Shaded, Shaded, false, &[false]), [vec![Painted]], "no reference parts: the model's picture alone"); + assert_eq!(pictures(Shaded, Visible, false, &[true]), Vec::>::new(), "reference parts alone, lines only"); +} + +/// A plate placed at the top, and a hinge placed twice holding a pin: with the hinge a reference +/// component, both pins are reference parts and the plate isn't; with the pin itself one, the +/// same; with none, nothing is. +#[test] +fn bodies_placed_through_a_reference_component_are_reference_parts() { + let reference_flags = |s: &Session| crate::drawing_view::shown(s).unwrap().bodies.iter().map(|b| b.reference).collect::>(); + let mut s = Session::default(); + run(&mut s, "component.create", json!({"name": "Plate"})); + run(&mut s, "solid.box", json!({"length": 40, "width": 20, "height": 3})); + run(&mut s, "component.activate", json!({"component": 0})); + run(&mut s, "component.create", json!({"name": "Hinge", "activate": false})); + run(&mut s, "component.create", json!({"name": "Pin", "parent": "Hinge"})); + run(&mut s, "solid.cylinder", json!({"diameter": 4, "height": 30})); + run(&mut s, "component.activate", json!({"component": 0})); + run(&mut s, "occurrence.copy", json!({"component": "Hinge", "translate": [0, 50, 0]})); + let pin = s.doc.find_component("Pin").unwrap(); + let pins = |s: &Session| s.world_state().bodies.iter().map(|b| b.component == pin).collect::>(); + assert_eq!(pins(&s).iter().filter(|p| **p).count(), 2, "{:?}", pins(&s)); + assert_eq!(reference_flags(&s), vec![false; pins(&s).len()]); + for reference in ["Hinge", "Pin"] { + run(&mut s, "component.metadata.set", json!({"component": reference, "bom_type": "reference"})); + assert_eq!(reference_flags(&s), pins(&s), "{reference}"); + run(&mut s, "component.metadata.set", json!({"component": reference, "bom_type": "normal"})); + } + run(&mut s, "component.metadata.set", json!({"component": "Plate", "bom_type": "reference"})); + assert_eq!(reference_flags(&s), pins(&s).iter().map(|p| !p).collect::>(), "the plate alone"); +} diff --git a/crates/engine/src/drawing_scene.rs b/crates/engine/src/drawing_scene.rs index 431f82cb0f..3ab1b7a502 100644 --- a/crates/engine/src/drawing_scene.rs +++ b/crates/engine/src/drawing_scene.rs @@ -27,8 +27,8 @@ use solidcraft_doc::drawing::{Drawing, Sheet}; use solidcraft_doc::drawing_view::TangentEdges; use solidcraft_doc::{DocError, ModelState, ids}; use solidcraft_geom::paper::text::TextRun; -use solidcraft_geom::paper::{Group, Item, MAX_RECORDS, Scene}; -use solidcraft_geom::{Loop2, Region2, Seg2, Vec2}; +use solidcraft_geom::paper::{Group, Item, MAX_RECORDS, Picture, Scene}; +use solidcraft_geom::{Aabb2, Loop2, Region2, Seg2, Vec2}; use crate::drawing_view::{CHORD_MM, LineRequest, MIN_MODEL_CHORD_MM, ShownSet, Stats, Unmeasured, paper_group}; use crate::params::bad; @@ -220,6 +220,7 @@ fn moved(group: &Group, by: Vec2) -> Group { Item::Stroke { path, closed, width_mm } => Item::Stroke { path: segs(path), closed: *closed, width_mm: *width_mm }, Item::Fill { region } => Item::Fill { region: Region2 { outer: ring(®ion.outer), holes: region.holes.iter().map(ring).collect() } }, Item::Text(run) => Item::Text(TextRun { origin: run.origin + by, ..run.clone() }), + Item::Picture(picture) => Item::Picture(Picture { rect: Aabb2 { min: picture.rect.min + by, max: picture.rect.max + by }, ..*picture }), }); Group { object: group.object, kind: group.kind, items: items.collect() } } diff --git a/crates/engine/src/drawing_scene_tests.rs b/crates/engine/src/drawing_scene_tests.rs index 8e42b6842e..d2ce880a97 100644 --- a/crates/engine/src/drawing_scene_tests.rs +++ b/crates/engine/src/drawing_scene_tests.rs @@ -2,10 +2,11 @@ use std::sync::Arc; use serde_json::{Value, json}; use solidcraft_doc::MAX_PLACEMENT_DEPTH; +use solidcraft_doc::drawing::DEFAULT_DPI; use solidcraft_doc::drawing::view::{View, ViewKind, ViewPlace}; use solidcraft_doc::drawing_view::{ViewDirection, ViewOverrides}; use solidcraft_geom::drawing_view::{NamedView, ProjectedSide}; -use solidcraft_geom::paper::ObjectKind; +use solidcraft_geom::paper::{NoPictures, ObjectKind}; use super::*; use crate::schema::drawing_scene::SheetSceneResult; @@ -137,7 +138,7 @@ fn a_sheet_is_its_border_then_its_views_by_id() { let replied: Vec<(Value, Value)> = reply["views"].as_array().unwrap().iter().map(|v| (v["view"].clone(), v["status"].clone())).collect(); assert_eq!(replied, order.map(|id| (json!(ids::encode(id)), json!("drawn")))); - let (svg, _) = solidcraft_io::paper::paper_svg(&scene.prepare().unwrap()).unwrap(); + let (svg, _) = solidcraft_io::paper::paper_svg(&scene.prepare().unwrap(), &NoPictures, DEFAULT_DPI).unwrap(); let svg = String::from_utf8(svg).unwrap(); let borders: Vec = svg.match_indices("data-kind=\"drawing_border\"").map(|(at, _)| at).collect(); let first_view = svg.find("data-kind=\"view\"").unwrap(); @@ -298,6 +299,15 @@ fn a_move_and_its_undo_project_nothing() { assert_eq!(bytes(&undone.scene), bytes(&before.scene)); } +/// A view's picture moves with its lines: its rectangle by exactly the offset, its pixels and +/// opacity as they were. +#[test] +fn a_moved_group_moves_its_picture() { + let picture = |min: Vec2, max: Vec2| Item::Picture(Picture { source: 3, rect: Aabb2 { min, max }, opacity: 0.5 }); + let group = Group { object: 7, kind: ObjectKind::View, items: vec![picture(Vec2::new(1.0, 2.0), Vec2::new(11.0, 7.0))] }; + assert_eq!(moved(&group, Vec2::new(50.0, -2.0)).items, [picture(Vec2::new(51.0, 0.0), Vec2::new(61.0, 5.0))]); +} + /// After a model change (going back to a checkpoint taken before a tower was added) both views are /// out of date and the last scene still shows. Each step draws one view from the model as it is /// now, and a view listed as up to date always draws as the built scene does. diff --git a/crates/engine/src/drawing_view.rs b/crates/engine/src/drawing_view.rs index fc4825afa9..784a467865 100644 --- a/crates/engine/src/drawing_view.rs +++ b/crates/engine/src/drawing_view.rs @@ -159,10 +159,14 @@ pub struct OnSheet { } /// Why a view drawing in `display` and `reference` styles can't be drawn yet, if it can't: shaded -/// views need pictures, and a paper scene (`scene`) has no dashed lines for hidden ones, of the -/// model or of reference parts. The snapshot and the sheet scene refuse alike. +/// views with lines need the view's exact lines, and a paper scene (`scene`) has neither pictures +/// for shaded ones nor dashed lines for hidden ones, of the model or of reference parts. The snapshot +/// and the sheet scene refuse alike. pub fn not_drawable(display: VisualStyle, reference: VisualStyle, scene: bool) -> Option<&'static str> { - if display.is_shaded() || reference.is_shaded() { + let lined = |style: VisualStyle| style.is_shaded() && style != VisualStyle::Shaded; + if lined(display) || lined(reference) { + Some("not supported yet: shaded views with lines need the view's exact lines (W06)") + } else if scene && (display.is_shaded() || reference.is_shaded()) { Some("not supported yet: shaded views need the drawing renderer (#5624)") } else if scene && (display == VisualStyle::VisibleHidden || reference == VisualStyle::VisibleHidden) { Some("not supported yet: hidden lines in the paper scene need dashed lines (#4890 A5)") diff --git a/crates/engine/src/drawing_view_lines.rs b/crates/engine/src/drawing_view_lines.rs index 3fc21c3e90..09b7be98d0 100644 --- a/crates/engine/src/drawing_view_lines.rs +++ b/crates/engine/src/drawing_view_lines.rs @@ -405,13 +405,13 @@ mod tests { .iter() .filter_map(|i| match i { Item::Stroke { path, closed, width_mm } => Some((path.len(), *closed, *width_mm)), - Item::Fill { .. } | Item::Text(_) => None, + Item::Fill { .. } | Item::Text(_) | Item::Picture(_) => None, }) .collect(); assert_eq!(strokes, [(2, false, 0.5), (1, true, 0.5), (3, true, 0.5), (1, false, 0.5)]); let chord = group.items.last().and_then(|i| match i { Item::Stroke { path, .. } => path.first().copied(), - Item::Fill { .. } | Item::Text(_) => None, + Item::Fill { .. } | Item::Text(_) | Item::Picture(_) => None, }); assert!(matches!(chord, Some(Seg2::Line { .. })), "{chord:?}"); let scene = solidcraft_geom::paper::Scene::new(Vec2::new(420.0, 297.0), vec![group]); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 891535987f..6a78abd493 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -16,6 +16,7 @@ pub mod control_auth; pub mod control_pending; pub mod cosmetic_threads; pub mod crash; +pub mod drawing_picture; pub mod drawing_scene; pub mod drawing_view; pub mod easyprint; diff --git a/crates/engine/src/product_scene.rs b/crates/engine/src/product_scene.rs index 05473cd0c2..c3b763da2e 100644 --- a/crates/engine/src/product_scene.rs +++ b/crates/engine/src/product_scene.rs @@ -1,5 +1,7 @@ //! Product extraction shares evaluated placement and appearance resolution, not viewport policy. +use solidcraft_doc::ModelBody; + use crate::geom::Aabb3; use crate::render::{FaceAppearance, ProductMesh, ProductScene}; use crate::{EngineError, Result, Session}; @@ -9,14 +11,20 @@ pub const MAX_PRODUCT_BODIES: usize = solidcraft_render::job::MAX_CPU_BODIES; /// Extract authoritative, visible geometry. Does not settle a live gesture implicitly. /// `render.scene` is the explicit model-reading command for that boundary. pub fn extract(s: &Session) -> Result { + extract_where(s, |b| s.body_visible(&b.name)) +} + +/// [`extract`] for the bodies `keep` picks, whatever the browser shows: a drawing view draws every +/// body the design places. +pub fn extract_where(s: &Session, keep: impl Fn(&ModelBody) -> bool) -> Result { if s.model.is_stale() { return Err(EngineError::Other("render scene is stale; finish the modelling gesture before previewing".into())); } let world = s.world_state(); let mut scene = ProductScene { meshes: Vec::new(), bounds: Aabb3::EMPTY }; - for body in world.bodies.iter().filter(|b| s.body_visible(&b.name)) { + for body in world.bodies.iter().filter(|b| keep(b)) { if scene.meshes.len() >= MAX_PRODUCT_BODIES { - return Err(EngineError::Other("render scene exceeds 262144 visible bodies".into())); + return Err(EngineError::Other("render scene exceeds 262144 bodies".into())); } let mesh = body.mesh(); scene.bounds = scene.bounds.union(&mesh.bounds()); @@ -96,6 +104,7 @@ mod tests { assert_eq!(active.meshes[1].opacity, 0.4, "no active-component fading"); s.visibility.hidden_bodies.push(first.meshes[1].name.clone()); assert_eq!(extract(&s).unwrap().meshes.len(), 1); + assert_eq!(extract_where(&s, |_| true).unwrap().meshes.len(), 2, "every body, the hidden one too"); assert!(Arc::ptr_eq(&doc, &s.doc)); assert_eq!(s.evaluations, 0); } diff --git a/crates/engine/src/schema/drawing_view.rs b/crates/engine/src/schema/drawing_view.rs index 00b60f45da..d44977e87e 100644 --- a/crates/engine/src/schema/drawing_view.rs +++ b/crates/engine/src/schema/drawing_view.rs @@ -482,6 +482,14 @@ pub struct ExtentsResult { pub provenance: &'static str, } +/// A view's picture: the pixel grid of its boundary at the sheet's dpi, rounded outward. +#[derive(Debug, Serialize, JsonSchema)] +pub struct PictureResult { + pub pixels: [u32; 2], + pub dpi: f64, + pub rect_mm: BoxResult, +} + /// A box on the sheet, in paper millimetres. #[derive(Debug, Serialize, JsonSchema)] pub struct BoxResult { @@ -713,6 +721,9 @@ pub struct SnapshotResult { /// With `scene: true`: the lines as a paper scene of the sheet. #[serde(skip_serializing_if = "Option::is_none")] pub scene: Option, + /// The view's picture, when something in it is shaded. + #[serde(skip_serializing_if = "Option::is_none")] + pub picture: Option, pub source: SourceResult, /// In the warn parameter-check mode: what the call's parameters got wrong. #[serde(default, skip_serializing_if = "Vec::is_empty")] diff --git a/crates/engine/src/schema/drawing_view_tests.rs b/crates/engine/src/schema/drawing_view_tests.rs index 017685e637..0354969dee 100644 --- a/crates/engine/src/schema/drawing_view_tests.rs +++ b/crates/engine/src/schema/drawing_view_tests.rs @@ -73,7 +73,7 @@ fn strict_keeps_todays_errors() { assert_strict_keeps_todays_errors(&[ ("drawing.view.snapshot", json!({"scale": "0:1"})), ("drawing.view.snapshot", json!({"view": "Front"})), - ("drawing.view.snapshot", json!({"display_style": "shaded"})), + ("drawing.view.snapshot", json!({"display_style": "shaded_visible"})), ("drawing.view.snapshot", json!({"sheet": {"dpi": 1e9}})), ]); } diff --git a/crates/geom/src/aabb2.rs b/crates/geom/src/aabb2.rs index 2cf9a8749b..857abf1c8f 100644 --- a/crates/geom/src/aabb2.rs +++ b/crates/geom/src/aabb2.rs @@ -1,9 +1,12 @@ //! A box in the plane, the 2D [`crate::Aabb3`]. +use serde::{Deserialize, Serialize}; + use crate::Vec2; /// Axis-aligned box in the plane; [`Aabb2::EMPTY`] (low +∞, high −∞) holds nothing. -#[derive(Clone, Copy, Debug, PartialEq)] +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct Aabb2 { pub min: Vec2, pub max: Vec2, diff --git a/crates/geom/src/drawing_sheet_tests.rs b/crates/geom/src/drawing_sheet_tests.rs index 089536eeb1..c479704739 100644 --- a/crates/geom/src/drawing_sheet_tests.rs +++ b/crates/geom/src/drawing_sheet_tests.rs @@ -622,6 +622,7 @@ fn drawn(group: &Group) -> Vec<(Vec<[i64; 4]>, bool, f64)> { Item::Stroke { path, closed, width_mm } => (ends(path), *closed, *width_mm), Item::Fill { region } => (ends(®ion.outer.segs), true, 0.0), Item::Text(_) => panic!("a border has no text"), + Item::Picture(_) => panic!("a sheet draws no pictures"), }) .collect() } diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index deb315bede..e7fba784db 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -32,6 +32,7 @@ mod mesh; pub mod mesh_distance; pub mod network; pub mod paper; +pub mod pixel_grid; mod plane; mod profile; pub mod revision; diff --git a/crates/geom/src/paper.rs b/crates/geom/src/paper.rs index f8f924b052..1bd0848cc9 100644 --- a/crates/geom/src/paper.rs +++ b/crates/geom/src/paper.rs @@ -3,6 +3,11 @@ //! [`Scene::validate`]. Text is runs of placed glyphs with the table of the fonts they use //! ([`text`]). Clips, line patterns and styles are later additions. //! +//! A picture (a shaded view, a logo) is a rectangle and the name of its pixels, never the pixels: +//! a writer asks a [`Pictures`] provider for them on the pixel grid it draws on +//! ([`crate::pixel_grid`]), so a PNG at 1200 dpi gets a picture drawn at 1200 dpi rather than one +//! stretched. +//! //! - Coordinates are millimetres from the sheet's lower-left corner, x to the right and y up; //! angles are radians, anticlockwise. A line's width is its width on paper, centred on its path. //! - Paint goes on in order: the groups in turn, then the items of each group in turn, each over @@ -25,23 +30,32 @@ //! every loop of a fill closed the same way; //! - a line width above 0 and at most [`MAX_LINE_MM`]; //! - the text and its fonts by [`text`]'s rules. +//! - a picture's rectangle within the coordinate bound and above 0 on each side, and its opacity +//! above 0 and at most 1. //! //! It does not certify simple loops, holes inside their outer loop or a path that never doubles //! back. Validation after parsing cannot bound parsing allocations: an encoded-input boundary must //! also limit bytes and nesting. //! //! Writers read a [`Prepared`] scene, which only [`Scene::prepare`] makes, so none reads one that -//! wasn't checked. Clipping will happen there too, once, before any writer (A8). +//! wasn't checked, and format it on their number grid ([`Prepared::on_grid`], [`grid`]'s rules), +//! so every format draws the same joints, closures and merges. Clipping will happen in +//! `prepare` too, once, before any writer (A8). use serde::{Deserialize, Serialize}; use std::f64::consts::FRAC_PI_2; -use crate::{EPS, Region2, Seg2, Vec2}; +use crate::pixel_grid::{GridWindow, PixelGrid, PixelGridError}; +use crate::{Aabb2, EPS, Region2, Seg2, Vec2}; pub mod annotate; +pub mod flatten; +pub mod grid; pub mod text; +pub use flatten::flatten; +pub use grid::{Grid, GridGroup, GridItem, GridPath, GridPoint, GridReport, OnGrid, Piece}; use text::{Font, TextRun}; /// The widest line a scene may draw, in millimetres. @@ -84,6 +98,7 @@ impl Group { Item::Stroke { path, .. } => path.len(), Item::Fill { region } => region.loops().fold(0usize, |n, l| n.saturating_add(1).saturating_add(l.segs.len())), Item::Text(run) => run.records(), + Item::Picture(_) => 0, }; self.items.iter().fold(1usize, |n, i| n.saturating_add(1).saturating_add(item(i))) } @@ -134,6 +149,47 @@ pub enum Item { Fill { region: Region2 }, /// A run of text, filled with its glyphs' outlines. Text(TextRun), + /// Pixels drawn into a rectangle. + Picture(Picture), +} + +/// A picture on the sheet: its pixels, named by `source`, drawn into `rect` over what is already +/// there, `opacity` times as strongly as their own alpha says. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Picture { + /// Which pixels, opaque here: a writer asks [`Pictures`] for them. + pub source: u64, + /// Where it sits, millimetres. + pub rect: Aabb2, + /// Above 0, up to 1 (as its pixels are). + pub opacity: f64, +} + +impl Picture { + /// Its own pixel grid at `dpi`, anchored at its rectangle's lower-left corner: what SVG and PDF + /// embed. + pub fn grid(&self, dpi: f64) -> std::result::Result { + let size = self.rect.size(); + PixelGrid::new(self.rect.min.x, self.rect.min.y, size.x, size.y, dpi) + } +} + +/// Where a writer gets a picture's pixels. +pub trait Pictures { + /// The pixels of picture `source` on `grid`, for the columns and rows of `window`: straight + /// (not premultiplied) alpha, sRGB, 8 bits a channel, RGBA, top row first, exactly `window`'s + /// size. + fn rows(&self, source: u64, grid: &PixelGrid, window: GridWindow) -> std::result::Result, String>; +} + +/// No pictures: a writer given it refuses any picture it meets. +pub struct NoPictures; + +impl Pictures for NoPictures { + fn rows(&self, source: u64, _: &PixelGrid, _: GridWindow) -> std::result::Result, String> { + Err(format!("no pixels for picture {source}")) + } } #[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] @@ -239,6 +295,15 @@ impl Scene { } } Item::Text(_) => {} + Item::Picture(Picture { rect, opacity, .. }) => { + let within = |v: f64| v.abs() <= MAX_COORDINATE_MM; + let corners = [rect.min.x, rect.min.y, rect.max.x, rect.max.y]; + require( + corners.into_iter().all(within) && rect.min.x < rect.max.x && rect.min.y < rect.max.y, + "a picture's rectangle is within 1e5 mm and above 0 on each side", + )?; + require(*opacity > 0.0 && *opacity <= 1.0, "a picture's opacity is above 0 and at most 1")?; + } } } let runs = self.groups.iter().flat_map(|g| &g.items).filter_map(|item| match item { diff --git a/crates/geom/src/paper/annotate_tests.rs b/crates/geom/src/paper/annotate_tests.rs index 12cc71abba..7b70b2629d 100644 --- a/crates/geom/src/paper/annotate_tests.rs +++ b/crates/geom/src/paper/annotate_tests.rs @@ -23,6 +23,7 @@ fn drawn(item: &Item) -> (Vec<[i64; 4]>, bool, Option) { Item::Stroke { path, closed, width_mm } => (ends(path), *closed, Some(*width_mm)), Item::Fill { region } => (ends(®ion.outer.segs), true, None), Item::Text(_) => panic!("an annotation draws no text"), + Item::Picture(picture) => panic!("an annotation draws no picture: {picture:?}"), } } diff --git a/crates/geom/src/paper/flatten.rs b/crates/geom/src/paper/flatten.rs new file mode 100644 index 0000000000..a39a88fb0e --- /dev/null +++ b/crates/geom/src/paper/flatten.rs @@ -0,0 +1,164 @@ +//! A path on a writer's grid as straight lines (#4890, A4), for a writer that draws only lines +//! (PNG): every point of every curve lies within the tolerance of them, and a path that would need +//! more than [`MAX_POINTS`] points is refused, never drawn coarser. It is the one flattener for the +//! paper scene: a writer that needs a curve as lines (PDF or DXF for what they can't hold) uses it. +//! +//! Each piece becomes the [`Seg2`] it is and takes [`Seg2::chords`]' steps, the bound +//! [`Seg2::polyline`] uses too. `polyline` itself doesn't fit here: it floors an arc's tolerance at +//! a millionth of its radius and caps a piece at 4096 steps, both coarser than asked, without a +//! word. An arc comes from the walk as its ends, radius and direction, which fix its centre (well +//! conditioned up to the walk's quarter turns). + +use super::{GridPath, GridPoint, Invalid, Piece, Result}; +use crate::{Seg2, Vec2}; + +/// The most points one path may flatten to. +pub const MAX_POINTS: usize = 1 << 20; + +/// `path`'s points, from its start, joined by lines within `tolerance` steps (above 0) of its +/// curves. A closed path's closing line is left to the reader, as on the grid. +pub fn flatten(path: &GridPath, tolerance: f64) -> Result> { + if !(tolerance > 0.0 && tolerance.is_finite()) { + return Err(Invalid("a flattening tolerance is above 0")); + } + let mut points = vec![v(path.start)]; + let mut at = v(path.start); + for piece in &path.pieces { + let to = v(piece.to()); + let seg = match *piece { + Piece::Line { .. } => Seg2::Line { a: at, b: to }, + Piece::Arc { radius, clockwise, .. } => { + let r = radius as f64; + let half = at.dist(to) / 2.0; + // The centre sits off the chord's middle, left of it for an anticlockwise turn. + let off = (r * r - half * half).max(0.0).sqrt(); + let side = (to - at).normalized().map_or(Vec2::new(0.0, 0.0), Vec2::perp); + let center = (at + to) / 2.0 + side * if clockwise { -off } else { off }; + let sweep = 2.0 * (half / r).min(1.0).asin() * if clockwise { -1.0 } else { 1.0 }; + Seg2::Arc { center, radius: r, start: (at - center).angle(), sweep } + } + Piece::Cubic { c1, c2, .. } => Seg2::Cubic { p0: at, p1: v(c1), p2: v(c2), p3: to }, + Piece::Quadratic { c, .. } => Seg2::Conic { a: at, apex: v(c), b: to, w: 1.0 }, + }; + let n = steps(seg.chords(tolerance), points.len())?; + points.extend((1..n).map(|i| seg.point_at(i as f64 / n as f64))); + points.push(to); + at = to; + } + Ok(points) +} + +fn v(p: GridPoint) -> Vec2 { + Vec2::new(p.x as f64, p.y as f64) +} + +/// `bound` rounded up to whole steps, at least one, refused when a path that has `so_far` points +/// would go over [`MAX_POINTS`]. +fn steps(bound: f64, so_far: usize) -> Result { + let n = bound.ceil().max(1.0); + if n.is_finite() && so_far as f64 + n <= MAX_POINTS as f64 { + Ok(n as usize) + } else { + Err(Invalid("a curve needs over 1,048,576 points at this resolution")) + } +} + +#[cfg(test)] +mod tests { + use std::f64::consts::{FRAC_PI_2, PI, TAU}; + + use super::*; + use crate::Seg2; + use crate::paper::{Grid, GridItem, Group, Item, ObjectKind, Scene}; + + /// The scene's one stroke along `seg` on the nanometre grid. + fn on_grid(seg: Seg2) -> GridPath { + let scene = Scene::new( + Vec2::new(420.0, 297.0), + vec![Group { object: 1, kind: ObjectKind::DrawingBorder, items: vec![Item::Stroke { path: vec![seg], closed: false, width_mm: 0.35 }] }], + ); + let on = scene.prepare().unwrap().on_grid(Grid::NANOMETRE).unwrap(); + let GridItem::Stroke { path, .. } = &on.groups[0].items[0] else { panic!("a stroke") }; + path.clone() + } + + fn distance_to_lines(p: Vec2, points: &[Vec2]) -> f64 { + points.windows(2).map(|w| p.extend(0.0).dist_to_segment(w[0].extend(0.0), w[1].extend(0.0))).fold(f64::INFINITY, f64::min) + } + + /// Every curve point within the tolerance of the lines, every line point on (or, for a chord, + /// within the tolerance of) the curve, in nanometres: arcs either way round of every size, + /// cubics and parabolas. + #[test] + fn every_curve_lies_within_the_tolerance_of_its_lines() { + let p = Vec2::new; + let mm = |x: f64| x * 1e6; + for tolerance in [1.0, 132.3, 1323.0, 1e5] { + for (radius, start, sweep) in [(10.0, 0.0, FRAC_PI_2), (10.0, 1.0, -FRAC_PI_2), (0.01, 0.3, TAU), (1e4, -2.0, -PI), (250.0, 0.2, 1e-4)] { + let center = p(100.0, 100.0); + let points = flatten(&on_grid(Seg2::Arc { center, radius, start, sweep }), tolerance).unwrap(); + let c = p(mm(center.x), mm(center.y)); + for q in &points { + assert!((q.dist(c) - mm(radius)).abs() < 4.0, "a point on the circle: r {radius}, {:?}", q); + } + for k in 0..=1000 { + let on = c + Vec2::from_angle(start + sweep * f64::from(k) / 1000.0) * mm(radius); + let d = distance_to_lines(on, &points); + assert!(d <= tolerance + 2.0, "arc r {radius} sweep {sweep}, tolerance {tolerance}: {d}"); + } + } + let cubic = [p(0.0, 0.0), p(10.0, 40.0), p(30.0, -20.0), p(40.0, 0.0)]; + let points = flatten(&on_grid(Seg2::Cubic { p0: cubic[0], p1: cubic[1], p2: cubic[2], p3: cubic[3] }), tolerance).unwrap(); + for k in 0..=1000 { + let (t, s) = (f64::from(k) / 1000.0, 1.0 - f64::from(k) / 1000.0); + let q = cubic[0] * (s * s * s) + cubic[1] * (3.0 * s * s * t) + cubic[2] * (3.0 * s * t * t) + cubic[3] * (t * t * t); + assert!(distance_to_lines(p(mm(q.x), mm(q.y)), &points) <= tolerance + 2.0, "cubic at {t}, tolerance {tolerance}"); + } + let (a, c, b) = (p(0.0, 0.0), p(10.0, 30.0), p(20.0, 0.0)); + let points = flatten(&on_grid(Seg2::Conic { a, apex: c, b, w: 1.0 }), tolerance).unwrap(); + for k in 0..=1000 { + let (t, s) = (f64::from(k) / 1000.0, 1.0 - f64::from(k) / 1000.0); + let q = a * (s * s) + c * (2.0 * s * t) + b * (t * t); + assert!(distance_to_lines(p(mm(q.x), mm(q.y)), &points) <= tolerance + 2.0, "parabola at {t}, tolerance {tolerance}"); + } + } + } + + /// As few points as the bounds allow, the last each piece's own end; lines as they are. + #[test] + fn the_point_counts_are_the_bounds() { + let p = Vec2::new; + // A quarter turn of 10 mm within 1 µm: 2·acos(1 − 1e3 / 1e7) a step, 56 steps. + let quarter = on_grid(Seg2::Arc { center: p(50.0, 50.0), radius: 10.0, start: 0.0, sweep: FRAC_PI_2 }); + let points = flatten(&quarter, 1e3).unwrap(); + assert_eq!(points.len(), 57); + assert_eq!(points.last().copied(), Some(v(quarter.pieces[0].to()))); + // A cubic whose largest second difference is √1700 ≈ 41.2 mm, within 1 µm: √(0.75 · 4.12e7 / 1e3) → 176. + let cubic = on_grid(Seg2::Cubic { p0: p(0.0, 0.0), p1: p(10.0, 40.0), p2: p(30.0, 40.0), p3: p(40.0, 0.0) }); + assert_eq!(flatten(&cubic, 1e3).unwrap().len(), 1 + 176); + // A parabola whose second difference is 60 mm, within 1 µm: √(0.25 · 6e7 / 1e3) → 123. + let parabola = on_grid(Seg2::Conic { a: p(0.0, 0.0), apex: p(10.0, 30.0), b: p(20.0, 0.0), w: 1.0 }); + assert_eq!(flatten(¶bola, 1e3).unwrap().len(), 1 + 123); + let line = on_grid(Seg2::Line { a: p(1.0, 2.0), b: p(3.0, 4.0) }); + assert_eq!(flatten(&line, 1e3).unwrap(), [p(1e6, 2e6), p(3e6, 4e6)]); + // A tolerance wider than the radius: eighth turns, so one chord for half a radian. + let tiny = on_grid(Seg2::Arc { center: p(50.0, 50.0), radius: 1e-3, start: 0.0, sweep: 0.5 }); + assert_eq!(flatten(&tiny, 1e4).unwrap().len(), 2); + } + + /// Over the cap is refused, never coarser; a tolerance must be above 0. + #[test] + fn too_many_points_or_no_tolerance_is_refused() { + let circle = on_grid(Seg2::Arc { center: Vec2::new(0.0, 0.0), radius: 1e5, start: 0.0, sweep: TAU }); + assert!(flatten(&circle, 1e3).is_ok()); + let e = flatten(&circle, 0.1).unwrap_err(); + assert!(e.0.contains("1,048,576"), "{e}"); + for tolerance in [0.0, -1.0, f64::NAN, f64::INFINITY] { + assert!(flatten(&circle, tolerance).is_err(), "{tolerance}"); + } + // The cap counts the points a path already has: it may reach the cap, never pass it. + assert_eq!(steps(10.0, MAX_POINTS - 10).ok(), Some(10)); + assert!(steps(10.0, MAX_POINTS - 9).is_err()); + assert!(steps(0.5, MAX_POINTS).is_err(), "a curve is at least one step"); + } +} diff --git a/crates/geom/src/paper/grid.rs b/crates/geom/src/paper/grid.rs new file mode 100644 index 0000000000..cac72a7632 --- /dev/null +++ b/crates/geom/src/paper/grid.rs @@ -0,0 +1,254 @@ +//! A prepared scene on a writer's number grid (#4890, C4): the one walk every writer formats, so +//! each format draws the same joints, closures and merges. +//! +//! - Every coordinate is a whole number of the grid's steps, x to the right and y up as in the +//! scene, so a joint is one point and a writer's flip or scale of whole numbers is exact. +//! - A closed path ends exactly on its start; a closing line is left to the writer's close +//! (SVG's `Z`, PDF's `h`). +//! - A piece ending within [`SLIVER_STEPS`] of where the path is merges into the next, and is +//! counted. A path's last piece has no next, so it is kept unless it has no length. +//! - A width under half a step is one step, and is counted. +//! - Circular arcs come in pieces of at most a quarter turn ([`arc_pieces`]). Ellipses, hyperbolas +//! and Béziers above degree 3 aren't walked yet (A3b): a scene with one is refused. + +use super::{Invalid, Item, ObjectKind, Picture, Prepared, Result, arc_pieces}; +use crate::{Seg2, Vec2}; + +/// The largest number put on a grid, in millimetres either way: far past any scene's 2e5. +const MAX_NUMBER_MM: f64 = 9e9; +/// Pieces ending this many steps or fewer from where the path is are merged into the next. +pub const SLIVER_STEPS: i64 = 10; + +/// A writer's number grid: `per_mm` steps a millimetre. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Grid { + per_mm: f64, +} + +impl Grid { + /// Whole nanometres, six decimals of a millimetre: SVG's numbers. + pub const NANOMETRE: Grid = Grid { per_mm: 1e6 }; + + /// A grid of `per_mm` steps a millimetre, from 1 to 1e9. A power of two keeps every number + /// exact in binary floating point, as a writer of 32-bit numbers (PDF) wants. + pub fn new(per_mm: f64) -> Option { + (1.0..=1e9).contains(&per_mm).then_some(Grid { per_mm }) + } + + pub fn per_mm(self) -> f64 { + self.per_mm + } + + /// `v` millimetres as whole steps. + pub fn steps(self, v: f64) -> Result { + let n = (v * self.per_mm).round(); + if n.abs() <= MAX_NUMBER_MM * self.per_mm { Ok(n as i64) } else { Err(Invalid("a number too large (or not a number) for the grid")) } + } + + pub fn point(self, p: Vec2) -> Result { + Ok(GridPoint { x: self.steps(p.x)?, y: self.steps(p.y)? }) + } +} + +/// A point on a grid, in whole steps. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct GridPoint { + pub x: i64, + pub y: i64, +} + +/// One piece of a path on the grid, from where the path is to `to`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Piece { + Line { + to: GridPoint, + }, + /// A circular arc of at most a quarter turn, of `radius` steps, turning clockwise (y up) when + /// `clockwise`. + Arc { + radius: i64, + clockwise: bool, + to: GridPoint, + }, + Cubic { + c1: GridPoint, + c2: GridPoint, + to: GridPoint, + }, + /// A parabola: a conic of weight 1 with its control point `c`. + Quadratic { + c: GridPoint, + to: GridPoint, + }, +} + +impl Piece { + pub fn to(&self) -> GridPoint { + match *self { + Piece::Line { to } | Piece::Arc { to, .. } | Piece::Cubic { to, .. } | Piece::Quadratic { to, .. } => to, + } + } +} + +/// A path on the grid: its pieces from `start`. A closed one ends on `start`, its closing line +/// left to the writer's close. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GridPath { + pub start: GridPoint, + pub pieces: Vec, + pub closed: bool, +} + +/// An item on the grid. +#[derive(Clone, Debug, PartialEq)] +pub enum GridItem { + /// A stroke `width` steps wide, at least one. + Stroke { width: i64, path: GridPath }, + /// A fill's loops, each closed. + Fill { loops: Vec }, + /// A picture as the scene has it: its pixels are placed by [`crate::pixel_grid`], not this grid. + Picture(Picture), +} + +/// A group's items on the grid, in paint order. +#[derive(Clone, Debug, PartialEq)] +pub struct GridGroup { + pub object: u64, + pub kind: ObjectKind, + pub items: Vec, +} + +/// What the grid made of a scene that the scene didn't say. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct GridReport { + /// Widths under half a step, made one step. + pub widths_snapped: usize, + /// Pieces too short for the grid, merged into the next. + pub pieces_merged: usize, +} + +/// A scene on a grid: the sheet's size, its groups in paint order, and the report. +#[derive(Clone, Debug, PartialEq)] +pub struct OnGrid { + pub size: GridPoint, + pub groups: Vec, + pub report: GridReport, +} + +impl Prepared<'_> { + /// The scene on `grid`, by the rules in this module's notes: what every writer formats. + pub fn on_grid(&self, grid: Grid) -> Result { + let mut report = GridReport::default(); + let mut groups = Vec::with_capacity(self.groups().len()); + for group in self.groups() { + let mut items = Vec::with_capacity(group.items.len()); + for item in &group.items { + match item { + Item::Stroke { path, closed, width_mm } => { + let Some(path) = walk(grid, path, *closed, &mut report)? else { continue }; + let mut width = grid.steps(*width_mm)?; + if width == 0 { + width = 1; + report.widths_snapped += 1; + } + items.push(GridItem::Stroke { width, path }); + } + Item::Fill { region } => { + let mut loops = Vec::new(); + for l in region.loops() { + loops.extend(walk(grid, &l.segs, true, &mut report)?); + } + items.push(GridItem::Fill { loops }); + } + Item::Text(_) => return Err(Invalid("not supported yet: text in an SVG or a sheet PNG (#4890 B2)")), + Item::Picture(picture) => items.push(GridItem::Picture(*picture)), + } + } + groups.push(GridGroup { object: group.object, kind: group.kind, items }); + } + Ok(OnGrid { size: grid.point(self.size_mm())?, groups, report }) + } +} + +/// `segs` on the grid (`None` when it has no pieces). +fn walk(grid: Grid, segs: &[Seg2], closed: bool, report: &mut GridReport) -> Result> { + let (Some(first), Some((last, body))) = (segs.first(), segs.split_last()) else { return Ok(None) }; + let start = grid.point(first.start())?; + let mut w = Walk { grid, start, at: start, pieces: Vec::with_capacity(segs.len()), report }; + for seg in body { + w.piece(seg, false, false)?; + } + if !closed { + w.piece(last, false, true)?; + } else if !matches!(last, Seg2::Line { .. }) { + w.piece(last, true, true)?; + } + Ok(Some(GridPath { start, pieces: w.pieces, closed })) +} + +/// A path being walked: where it started and where it is. +struct Walk<'r> { + grid: Grid, + start: GridPoint, + at: GridPoint, + pieces: Vec, + report: &'r mut GridReport, +} + +impl Walk<'_> { + /// Whether a piece ending at `end` is too short to keep: it merges into the next. A path's + /// last piece (`keep`) has no next, so it is kept unless it has no length: an open path ends + /// where it ends, a closed one on its start exactly. + fn merged(&mut self, end: GridPoint, keep: bool) -> bool { + if keep { + return end == self.at; + } + let (dx, dy) = (end.x - self.at.x, end.y - self.at.y); + let short = dx.abs() <= SLIVER_STEPS && dy.abs() <= SLIVER_STEPS && dx * dx + dy * dy <= SLIVER_STEPS * SLIVER_STEPS; + if short { + self.report.pieces_merged += 1; + } + short + } + + fn push(&mut self, piece: Piece) { + self.at = piece.to(); + self.pieces.push(piece); + } + + /// One piece, from where the path is: `closing` makes its end the path's start, and `keep` + /// marks the path's last piece ([`Walk::merged`]). + fn piece(&mut self, seg: &Seg2, closing: bool, keep: bool) -> Result<()> { + let end = if closing { self.start } else { self.grid.point(seg.end())? }; + match *seg { + Seg2::Line { .. } => { + if !self.merged(end, keep) { + self.push(Piece::Line { to: end }); + } + } + Seg2::Arc { center, radius, start, sweep } => { + let (radius_steps, clockwise) = (self.grid.steps(radius)?, sweep < 0.0); + let mut ends = arc_pieces(center, radius, start, sweep).peekable(); + while let Some(p) = ends.next() { + let final_piece = ends.peek().is_none(); + let to = if final_piece { end } else { self.grid.point(p)? }; + if !self.merged(to, keep && final_piece) { + self.push(Piece::Arc { radius: radius_steps, clockwise, to }); + } + } + } + Seg2::Cubic { p1, p2, .. } => { + let (c1, c2) = (self.grid.point(p1)?, self.grid.point(p2)?); + self.push(Piece::Cubic { c1, c2, to: end }); + } + Seg2::Conic { apex, w, .. } if (w - 1.0).abs() <= 1e-12 => { + let c = self.grid.point(apex)?; + self.push(Piece::Quadratic { c, to: end }); + } + Seg2::Conic { .. } | Seg2::Bezier { .. } => { + return Err(Invalid("not supported yet: ellipses, hyperbolas and Béziers above degree 3 on paper (#4890, A3b)")); + } + } + Ok(()) + } +} diff --git a/crates/geom/src/paper_tests.rs b/crates/geom/src/paper_tests.rs index 6d47898207..27c1421b1a 100644 --- a/crates/geom/src/paper_tests.rs +++ b/crates/geom/src/paper_tests.rs @@ -1,4 +1,4 @@ -use std::f64::consts::{PI, TAU}; +use std::f64::consts::{FRAC_PI_2, PI, TAU}; use serde_json::json; @@ -263,6 +263,8 @@ fn at_most_100_000_records_are_counted_before_any_geometry() { scene(vec![group(vec![fill(polygon(5), holes.clone())])]).validate().unwrap(); assert_eq!(group(vec![fill(polygon(5), holes.clone())]).records(), MAX_RECORDS); assert_eq!(scene(vec![group(vec![fill(polygon(6), holes)])]).validate(), Err(REFUSED)); + // A picture is one record, as an item: with its group, 2. + assert_eq!(group(vec![picture(0.0, 0.0, 10.0, 10.0, 1.0)]).records(), 2); // Counted first: an over-budget scene with a non-finite piece reports the count. let mut bad = steps(MAX_RECORDS - 1); bad[0] = line(p(f64::NAN, 0.0), p(1.0, 0.0)); @@ -306,3 +308,137 @@ fn unknown_fields_kinds_and_ids_are_refused_when_read() { v["groups"][0]["object"] = json!(u64::MAX); read(v).unwrap().validate().unwrap(); } + +/// A grid counts whole steps: half a step and less rounds to the nearer, -0 is 0, and a number +/// too large or not a number is refused. A grid has 1 to 1e9 steps a millimetre. +#[test] +fn a_grid_counts_whole_steps_and_refuses_what_it_cannot_hold() { + let nm = Grid::NANOMETRE; + assert_eq!((nm.steps(-0.0).unwrap(), nm.steps(4e-7).unwrap(), nm.steps(6e-7).unwrap()), (0, 0, 1)); + assert_eq!(nm.steps(9e9).unwrap(), 9_000_000_000_000_000); + for v in [f64::NAN, f64::INFINITY, 1e10, -1e10] { + assert!(nm.steps(v).is_err(), "{v}"); + } + for per_mm in [1.0, 2048.0, 1e9] { + assert_eq!(Grid::new(per_mm).map(Grid::per_mm), Some(per_mm)); + } + for per_mm in [0.0, 0.999, 1e9 + 1.0, -2048.0, f64::NAN, f64::INFINITY] { + assert!(Grid::new(per_mm).is_none(), "{per_mm}"); + } +} + +/// The A3 border on the nanometre grid: y up as in the scene, the closing line left to the +/// writer's close, each fill loop closed, nothing snapped or merged. +#[test] +fn a_scene_on_the_grid_keeps_y_up_and_leaves_the_closing_line_to_the_writer() { + let on = a3().prepare().unwrap().on_grid(Grid::NANOMETRE).unwrap(); + let g = |x: i64, y: i64| GridPoint { x: x * 1_000_000, y: y * 1_000_000 }; + assert_eq!(on.size, g(420, 297)); + let group = &on.groups[0]; + assert_eq!((group.object, group.kind, group.items.len()), (1, ObjectKind::DrawingBorder, 3)); + let GridItem::Stroke { width, path } = &group.items[0] else { panic!("{:?}", group.items[0]) }; + assert_eq!(*width, 700_000); + let lines = |points: &[GridPoint]| points.iter().map(|&to| Piece::Line { to }).collect::>(); + assert_eq!(*path, GridPath { start: g(20, 10), pieces: lines(&[g(410, 10), g(410, 287), g(20, 287)]), closed: true }); + let GridItem::Fill { loops } = &group.items[1] else { panic!("{:?}", group.items[1]) }; + assert_eq!(*loops, [GridPath { start: g(0, 0), pieces: lines(&[g(10, 0), g(10, 5), g(0, 5)]), closed: true }]); + assert_eq!(on.report, GridReport::default()); +} + +/// Slivers are measured in the grid's own steps: a 5 mm piece goes straight on to the next on a +/// 1 mm grid, and stays on the nanometre grid. An arc's quarters keep its radius and turn, in +/// steps; a width under half a step is one step. A curve the walk can't take yet is refused. +#[test] +fn slivers_and_radii_are_in_the_grids_own_steps() { + let millimetre = Grid::new(1.0).unwrap(); + let walk = |scene: &Scene, grid: Grid| scene.prepare().unwrap().on_grid(grid).unwrap(); + let path = |on: &OnGrid| match &on.groups[0].items[0] { + GridItem::Stroke { width, path } => (*width, path.clone()), + other => panic!("{other:?}"), + }; + let three = open(vec![line(p(0.0, 0.0), p(100.0, 0.0)), line(p(100.0, 0.0), p(105.0, 0.0)), line(p(105.0, 0.0), p(200.0, 0.0))]); + let coarse = walk(&three, millimetre); + let to = |x: i64| Piece::Line { to: GridPoint { x, y: 0 } }; + assert_eq!(path(&coarse).1.pieces, [to(100), to(200)]); + assert_eq!(coarse.report, GridReport { widths_snapped: 1, pieces_merged: 1 }, "0.35 mm is under half a 1 mm step"); + assert_eq!(path(&coarse).0, 1); + // The boundary both ways: 10 steps along x merge, 11 don't; a (6, 8) step merges, (7, 8) doesn't. + for (dx, dy, merged) in [(10.0, 0.0, 1), (11.0, 0.0, 0), (6.0, 8.0, 1), (7.0, 8.0, 0)] { + let (a, b) = (p(100.0, 0.0), p(100.0 + dx, dy)); + let scene = open(vec![line(p(0.0, 0.0), a), line(a, b), line(b, p(200.0, 50.0))]); + assert_eq!(walk(&scene, millimetre).report.pieces_merged, merged, "({dx}, {dy})"); + } + let fine = walk(&three, Grid::NANOMETRE); + assert_eq!((path(&fine).1.pieces.len(), fine.report), (3, GridReport::default())); + // A clockwise half turn of radius 10 round (50, 50) from the top: two quarters. + let half = open(vec![Seg2::Arc { center: p(50.0, 50.0), radius: 10.0, start: FRAC_PI_2, sweep: -PI }]); + let arc = |x, y| Piece::Arc { radius: 10, clockwise: true, to: GridPoint { x, y } }; + assert_eq!( + path(&walk(&half, millimetre)).1, + GridPath { start: GridPoint { x: 50, y: 60 }, pieces: vec![arc(60, 50), arc(50, 40)], closed: false } + ); + let ellipse = open(vec![Seg2::Conic { a: p(0.0, 0.0), apex: p(10.0, 10.0), b: p(20.0, 0.0), w: 0.5 }]); + let refused = ellipse.prepare().unwrap().on_grid(Grid::NANOMETRE).unwrap_err(); + assert!(refused.0.contains("not supported yet"), "{refused}"); +} + +fn picture(x0: f64, y0: f64, x1: f64, y1: f64, opacity: f64) -> Item { + Item::Picture(Picture { source: 9, rect: Aabb2 { min: p(x0, y0), max: p(x1, y1) }, opacity }) +} + +/// A picture is its rectangle, the name of its pixels and its opacity, read and written as such. +#[test] +fn a_picture_round_trips_and_its_grid_is_its_rectangles() { + let scene = sheet(vec![picture(20.0, 30.0, 120.0, 80.0, 1.0)]); + scene.validate().unwrap(); + let value = serde_json::to_value(&scene).unwrap(); + assert_eq!( + value["groups"][0]["items"][0], + json!({"kind": "picture", "source": 9, "rect": {"min": [20.0, 30.0], "max": [120.0, 80.0]}, "opacity": 1.0}) + ); + let restored: Scene = serde_json::from_value(value.clone()).unwrap(); + assert_eq!(restored, scene); + let Item::Picture(picture) = &scene.groups[0].items[0] else { panic!("a picture") }; + let grid = picture.grid(300.0).unwrap(); + assert_eq!((grid.x0, grid.y0, grid.width, grid.height), (20.0, 30.0, 1182, 591)); + for (at, key) in [("/groups/0/items/0", "pixels"), ("/groups/0/items/0/rect", "z")] { + let mut v = value.clone(); + v.pointer_mut(at).unwrap()[key] = json!(1); + assert!(serde_json::from_value::(v).is_err(), "an unknown field at {at:?}"); + } + let mut v = value; + v["groups"][0]["items"][0].as_object_mut().unwrap().remove("source"); + assert!(serde_json::from_value::(v).is_err()); +} + +#[test] +fn a_pictures_rectangle_has_area_within_the_bound_and_its_opacity_is_above_0_and_at_most_1() { + const RECTANGLE: Invalid = Invalid("a picture's rectangle is within 1e5 mm and above 0 on each side"); + const OPACITY: Invalid = Invalid("a picture's opacity is above 0 and at most 1"); + for item in [picture(-1e5, -1e5, 1e5, 1e5, 1.0), picture(0.0, 0.0, 1e-9, 1e-9, 1e-9), picture(10.0, 10.0, 11.0, 11.0, 0.5)] { + sheet(vec![item]).validate().unwrap(); + } + for (x0, y0, x1, y1) in [ + (10.0, 10.0, 10.0, 20.0), + (10.0, 10.0, 20.0, 10.0), + (20.0, 10.0, 10.0, 20.0), + (10.0, 20.0, 20.0, 10.0), + (0.0, 0.0, 1e5f64.next_up(), 1.0), + (-1e5 - 1e-6, 0.0, 1.0, 1.0), + (f64::NAN, 0.0, 1.0, 1.0), + (0.0, 0.0, 1.0, f64::INFINITY), + (f64::NEG_INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::INFINITY), + ] { + assert_eq!(sheet(vec![picture(x0, y0, x1, y1, 1.0)]).validate(), Err(RECTANGLE), "{x0} {y0} {x1} {y1}"); + } + assert_eq!(sheet(vec![Item::Picture(Picture { source: 1, rect: Aabb2::EMPTY, opacity: 1.0 })]).validate(), Err(RECTANGLE)); + for opacity in [0.0, -0.0, -0.5, 1f64.next_up(), 2.0, f64::NAN, f64::INFINITY] { + assert_eq!(sheet(vec![picture(10.0, 10.0, 20.0, 20.0, opacity)]).validate(), Err(OPACITY), "{opacity}"); + } + // A picture is one record. + let group = Group { object: 1, kind: ObjectKind::DrawingBorder, items: vec![picture(10.0, 10.0, 20.0, 20.0, 1.0); MAX_RECORDS - 1] }; + Scene::new(p(420.0, 297.0), vec![group.clone()]).validate().unwrap(); + let mut over = group; + over.items.push(picture(10.0, 10.0, 20.0, 20.0, 1.0)); + assert_eq!(Scene::new(p(420.0, 297.0), vec![over]).validate(), Err(Invalid("more than 100,000 groups, items, loops and pieces"))); +} diff --git a/crates/geom/src/pixel_grid.rs b/crates/geom/src/pixel_grid.rs new file mode 100644 index 0000000000..8132f6927e --- /dev/null +++ b/crates/geom/src/pixel_grid.rs @@ -0,0 +1,218 @@ +//! The pixel grid a picture of a paper rectangle is drawn on (#5624): the one rule the picture +//! renderer and the writers that place its pixels share, so a picture and the lines drawn over it +//! register exactly. +//! +//! - Pixels are `p = 25.4 / dpi` millimetres square. +//! - The grid is anchored at the rectangle's lower-left corner `(x0, y0)` and rounds outward: a +//! `w × h` mm rectangle is `W = ceil(w / p − 1e-9)` by `H = ceil(h / p − 1e-9)` pixels, so the +//! picture covers `[x0, x0 + W·p] × [y0, y0 + H·p]` and any overhang is at the top and the right. +//! (The 1e-9 keeps a side that is a whole number of pixels, such as 25.4 mm at 300 dpi, from +//! gaining one through rounding.) +//! - Column `i` covers `[x0 + i·p, x0 + (i + 1)·p)`. Rows run top-down, as an image is stored: +//! row `r` covers `[y0 + (H − r − 1)·p, y0 + (H − r)·p)`. +//! - A pixel is sampled at its centre, or at a quarter and three quarters of the way across it +//! in each direction when it is sampled 2 × 2. +//! - A whole sheet's grid ([`PixelGrid::sheet`]) is anchored at its top-left corner instead, as an +//! image of the sheet is drawn, and a picture on the sheet is drawn into its window of that grid +//! ([`PixelGrid::window`]), so placing it is a copy of whole pixels. + +use crate::{Aabb2, Vec2}; + +/// The longest side a grid may have, in pixels. +pub const MAX_SIDE_PX: u32 = 65_536; + +/// The pixel grid of one paper rectangle. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PixelGrid { + /// The lower-left corner, paper millimetres. + pub x0: f64, + pub y0: f64, + /// The pixel size, millimetres. + pub pitch: f64, + /// Columns and rows. + pub width: u32, + pub height: u32, +} + +/// Columns `x0..x1` and rows `y0..y1` of a grid, row 0 at the top. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct GridWindow { + pub x0: u32, + pub y0: u32, + pub x1: u32, + pub y1: u32, +} + +/// Why a rectangle has no grid. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum PixelGridError { + /// A coordinate, a size or the dpi is not finite, or a size or the dpi is not above 0. + Invalid, + /// A side would be more than [`MAX_SIDE_PX`] pixels. + TooLarge { pixels: f64 }, +} + +impl std::fmt::Display for PixelGridError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Invalid => write!(f, "a picture needs a finite position and a size and dpi above 0"), + Self::TooLarge { pixels } => write!(f, "a picture side of {pixels:.0} px is over the limit of {MAX_SIDE_PX} px"), + } + } +} + +impl std::error::Error for PixelGridError {} + +impl PixelGrid { + /// The grid of the `width_mm × height_mm` rectangle whose lower-left corner is `(x0, y0)`, at + /// `dpi`. + pub fn new(x0: f64, y0: f64, width_mm: f64, height_mm: f64, dpi: f64) -> Result { + if ![x0, y0, width_mm, height_mm, dpi].iter().all(|v| v.is_finite()) || width_mm <= 0.0 || height_mm <= 0.0 || dpi <= 0.0 { + return Err(PixelGridError::Invalid); + } + let pitch = 25.4 / dpi; + let side = |mm: f64| { + let pixels = (mm / pitch - 1e-9).ceil().max(1.0); + if pixels.is_finite() && pixels <= f64::from(MAX_SIDE_PX) { Ok(pixels as u32) } else { Err(PixelGridError::TooLarge { pixels }) } + }; + Ok(PixelGrid { x0, y0, pitch, width: side(width_mm)?, height: side(height_mm)? }) + } + + /// The grid of a whole `size_mm` sheet at `dpi`, anchored at its top-left corner: row 0's top + /// is the sheet's top edge, and any overhang is at the bottom and the right. + pub fn sheet(size_mm: Vec2, dpi: f64) -> Result { + let grid = PixelGrid::new(0.0, 0.0, size_mm.x, size_mm.y, dpi)?; + Ok(PixelGrid { y0: size_mm.y - f64::from(grid.height) * grid.pitch, ..grid }) + } + + /// The columns and rows of this grid that meet `rect` (paper millimetres) over more than + /// 1e-9 of a pixel, so a side on a pixel edge takes no sliver of the pixel beyond it; `None` + /// when they share no pixel or `rect` isn't finite. + pub fn window(&self, rect: Aabb2) -> Option { + if rect.is_empty() || !rect.min.is_finite() || !rect.max.is_finite() { + return None; + } + // Pixel coordinates: columns from the left edge, rows down from the top edge. + let top = self.y0 + f64::from(self.height) * self.pitch; + let (columns, rows) = (f64::from(self.width), f64::from(self.height)); + let x0 = ((rect.min.x - self.x0) / self.pitch + 1e-9).floor().max(0.0); + let x1 = ((rect.max.x - self.x0) / self.pitch - 1e-9).ceil().min(columns); + let y0 = ((top - rect.max.y) / self.pitch + 1e-9).floor().max(0.0); + let y1 = ((top - rect.min.y) / self.pitch - 1e-9).ceil().min(rows); + (x0 < x1 && y0 < y1).then_some(GridWindow { x0: x0 as u32, y0: y0 as u32, x1: x1 as u32, y1: y1 as u32 }) + } + + /// Columns times rows. + pub fn pixels(&self) -> u64 { + u64::from(self.width) * u64::from(self.height) + } + + /// The paper x range of column `i`. + pub fn column(&self, i: u32) -> (f64, f64) { + (self.x0 + f64::from(i) * self.pitch, self.x0 + (f64::from(i) + 1.0) * self.pitch) + } + + /// The paper y range of row `r` (row 0 is the top). + pub fn row(&self, r: u32) -> (f64, f64) { + let from_bottom = f64::from(self.height) - f64::from(r); + (self.y0 + (from_bottom - 1.0) * self.pitch, self.y0 + from_bottom * self.pitch) + } + + /// The paper rectangle the whole grid covers: `(x0, y0, x1, y1)`. + pub fn covers(&self) -> (f64, f64, f64, f64) { + (self.x0, self.y0, self.x0 + f64::from(self.width) * self.pitch, self.y0 + f64::from(self.height) * self.pitch) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 100 mm at 300 dpi is 1181.1 pixels, so 1182; a whole number of pixels stays whole. + #[test] + fn sides_round_outward_and_whole_sides_stay_whole() { + let g = PixelGrid::new(10.0, 20.0, 100.0, 50.0, 300.0).unwrap(); + assert_eq!((g.width, g.height), (1182, 591)); + assert!((g.pitch - 25.4 / 300.0).abs() < 1e-15); + let whole = PixelGrid::new(0.0, 0.0, 25.4, 2.54, 300.0).unwrap(); + assert_eq!((whole.width, whole.height), (300, 30)); + // 25 pixels of 0.1 mm: 2.5 / 0.1 is 25.000000000000004 in floating point. + assert_eq!(PixelGrid::new(0.0, 0.0, 2.5, 2.5, 254.0).unwrap().width, 25); + assert_eq!(PixelGrid::new(0.0, 0.0, 0.001, 0.001, 300.0).unwrap().pixels(), 1, "anything at all is one pixel"); + } + + /// The grid sits on the lower-left corner; its overhang is at the top and the right, and row 0 + /// is the top row. + #[test] + fn the_grid_is_anchored_lower_left_and_rows_run_top_down() { + let g = PixelGrid::new(10.0, 20.0, 100.0, 50.0, 300.0).unwrap(); + let (x0, y0, x1, y1) = g.covers(); + assert_eq!((x0, y0), (10.0, 20.0)); + assert!(x1 >= 110.0 && x1 - 110.0 < g.pitch && y1 >= 70.0 && y1 - 70.0 < g.pitch, "{x1} {y1}"); + assert_eq!(g.column(0).0, 10.0); + assert!((g.column(g.width - 1).1 - x1).abs() < 1e-9); + let (bottom, top) = g.row(g.height - 1); + assert!((bottom - 20.0).abs() < 1e-12 && (top - 20.0 - g.pitch).abs() < 1e-12, "the last row is the bottom: {bottom} {top}"); + assert!((g.row(0).1 - y1).abs() < 1e-9, "the first row is the top"); + // Not the paper rectangle's own top: the overhang is there. + assert!(g.row(0).1 > 70.0); + } + + #[test] + fn hostile_rectangles_are_refused() { + for (x, y, w, h, dpi) in [ + (f64::NAN, 0.0, 1.0, 1.0, 300.0), + (0.0, f64::INFINITY, 1.0, 1.0, 300.0), + (0.0, 0.0, 0.0, 1.0, 300.0), + (0.0, 0.0, 1.0, -1.0, 300.0), + (0.0, 0.0, 1.0, 1.0, 0.0), + (0.0, 0.0, 1.0, 1.0, f64::NAN), + ] { + assert_eq!(PixelGrid::new(x, y, w, h, dpi), Err(PixelGridError::Invalid), "{x} {y} {w} {h} {dpi}"); + } + // A0's long side at 1200 dpi fits; at 1500 dpi it does not. + assert_eq!(PixelGrid::new(0.0, 0.0, 1189.0, 841.0, 1200.0).unwrap().width, 56_174); + assert!(matches!(PixelGrid::new(0.0, 0.0, 1189.0, 841.0, 1500.0), Err(PixelGridError::TooLarge { .. }))); + assert!(matches!(PixelGrid::new(0.0, 0.0, 1e300, 1.0, 300.0), Err(PixelGridError::TooLarge { .. }))); + } + + /// A sheet's grid starts at its top edge: an A3 landscape sheet at 300 dpi is 4961 × 3508 + /// pixels, its top row's top at 297 mm and its overhang below the sheet's bottom edge. + #[test] + fn a_sheets_grid_is_anchored_at_its_top_left_corner() { + let g = PixelGrid::sheet(Vec2::new(420.0, 297.0), 300.0).unwrap(); + assert_eq!((g.width, g.height), (4961, 3508)); + assert_eq!(g.x0, 0.0); + assert!((g.row(0).1 - 297.0).abs() < 1e-9, "{:?}", g.row(0)); + let (bottom, _) = g.row(g.height - 1); + assert!(bottom < 0.0 && bottom > -g.pitch, "the overhang is at the bottom: {bottom}"); + assert_eq!(PixelGrid::sheet(Vec2::new(420.0, f64::NAN), 300.0), Err(PixelGridError::Invalid)); + } + + /// A window takes the pixels a rectangle meets: exactly its own on pixel edges, the partly + /// covered ones too a little inside them, and none outside the grid. + #[test] + fn a_window_takes_the_pixels_a_rectangle_meets() { + // 1 mm pixels on a 100 × 50 mm sheet, so row r spans y 49 − r to 50 − r. + let g = PixelGrid::sheet(Vec2::new(100.0, 50.0), 25.4).unwrap(); + let rect = |x0: f64, y0: f64, x1: f64, y1: f64| Aabb2 { min: Vec2::new(x0, y0), max: Vec2::new(x1, y1) }; + let window = |x0, y0, x1, y1| Some(GridWindow { x0, y0, x1, y1 }); + assert_eq!(g.window(rect(40.0, 20.0, 60.0, 30.0)), window(40, 20, 60, 30)); + assert_eq!(g.window(rect(39.6, 20.0, 60.0, 30.4)), window(39, 19, 60, 30)); + // A side a hair past a pixel edge (floating-point noise) takes no sliver. + assert_eq!(g.window(rect(40.0 - 1e-12, 20.0, 60.0 + 1e-12, 30.0)), window(40, 20, 60, 30)); + // Clipped to the grid; outside it, or with no area, nothing. + assert_eq!(g.window(rect(-10.0, -10.0, 5.0, 5.0)), window(0, 45, 5, 50)); + assert_eq!(g.window(rect(100.0, 0.0, 120.0, 10.0)), None); + assert_eq!(g.window(rect(10.0, 10.0, 10.0, 20.0)), None); + assert_eq!(g.window(Aabb2::EMPTY), None); + assert_eq!(g.window(rect(f64::NAN, 0.0, 10.0, 10.0)), None); + assert_eq!(g.window(rect(0.0, 0.0, f64::INFINITY, 10.0)), None); + // On a 300 dpi grid, a picture's rectangle on whole pixels is exactly its own grid's size. + let g = PixelGrid::sheet(Vec2::new(420.0, 297.0), 300.0).unwrap(); + let p = g.pitch; + let (left, top) = (100.0 * p, g.row(200).1); + let w = g.window(rect(left, top - 591.0 * p, left + 1182.0 * p, top)).unwrap(); + assert_eq!((w.x0, w.x1 - w.x0, w.y0, w.y1 - w.y0), (100, 1182, 200, 591)); + } +} diff --git a/crates/geom/src/profile.rs b/crates/geom/src/profile.rs index 21437c5ff4..64d260e74a 100644 --- a/crates/geom/src/profile.rs +++ b/crates/geom/src/profile.rs @@ -571,36 +571,36 @@ impl Seg2 { Seg2::Cubic { .. } | Seg2::Conic { .. } | Seg2::Bezier { .. } => self.integrate(|s, t| 0.5 * s.point_at(t).cross(s.derivative(t))), } } - /// Polyline approximation (including both end points) with at most `tol` chord error. - pub fn polyline(&self, tol: f64) -> Vec { + /// How many equal steps of `t` keep every chord within `tol` (above 0) of the segment, unrounded + /// and with no floor or cap: an arc by its chord's bulge, the others by the largest second + /// difference of their controls (Wang's bound). + pub fn chords(&self, tol: f64) -> f64 { + // A chord over 1/k of t strays at most an eighth of the second derivative's bound over k². + let bound = |d2: f64| (d2 / (8.0 * tol)).sqrt(); match *self { - Seg2::Line { a, b } => vec![a, b], + Seg2::Line { .. } => 1.0, Seg2::Arc { radius, sweep, .. } => { - let tol = tol.max(radius.abs() * 1e-6).max(1e-9); - let step = if tol < radius { 2.0 * (1.0 - tol / radius).clamp(-1.0, 1.0).acos() } else { TAU / 8.0 }; - let n = ((sweep.abs() / step.max(1e-6)).ceil() as usize).clamp(2, 4096); - (0..=n).map(|i| self.point_at(i as f64 / n as f64)).collect() - } - Seg2::Cubic { p0, p1, p2, p3 } => { - // Chord error of n pieces ≈ max second difference / (8 n²). - let bow = (p0 - p1 * 2.0 + p2).len().max((p1 - p2 * 2.0 + p3).len()) * 6.0; - let n = ((bow / (8.0 * tol.max(1e-9))).sqrt().ceil() as usize).clamp(2, 4096); - (0..=n).map(|i| self.point_at(i as f64 / n as f64)).collect() - } - Seg2::Conic { a, apex, b, w } => { - let bow = (a - apex * 2.0 + b).len() * 2.0 * w.max(1.0); - let n = ((bow / (8.0 * tol.max(1e-9))).sqrt().ceil() as usize).clamp(2, 4096); - (0..=n).map(|i| self.point_at(i as f64 / n as f64)).collect() + sweep.abs() / if tol < radius { 2.0 * (1.0 - tol / radius).clamp(-1.0, 1.0).acos() } else { TAU / 8.0 } } + Seg2::Cubic { p0, p1, p2, p3 } => bound((p0 - p1 * 2.0 + p2).len().max((p1 - p2 * 2.0 + p3).len()) * 6.0), + Seg2::Conic { a, apex, b, w } => bound((a - apex * 2.0 + b).len() * 2.0 * w.max(1.0)), Seg2::Bezier { n, ref p } => { // Second derivative ≤ n(n−1) × the largest second difference of the controls. let c = Self::bez(n, p); - let d2 = c.windows(3).map(|w| (w[0] - w[1] * 2.0 + w[2]).len()).fold(0.0, f64::max) * (n as f64) * (n as f64 - 1.0); - let k = ((d2 / (8.0 * tol.max(1e-9))).sqrt().ceil() as usize).clamp(2, 4096); - (0..=k).map(|i| self.point_at(i as f64 / k as f64)).collect() + bound(c.windows(3).map(|w| (w[0] - w[1] * 2.0 + w[2]).len()).fold(0.0, f64::max) * (n as f64) * (n as f64 - 1.0)) } } } + /// Polyline approximation (including both end points) with at most `tol` chord error + /// ([`Seg2::chords`]) in 2 to 4096 pieces, an arc's tolerance at least a millionth of its radius. + pub fn polyline(&self, tol: f64) -> Vec { + if let Seg2::Line { a, b } = *self { + return vec![a, b]; + } + let tol = if let Seg2::Arc { radius, .. } = *self { tol.max(radius.abs() * 1e-6) } else { tol }.max(1e-9); + let n = (self.chords(tol).ceil() as usize).clamp(2, 4096); + (0..=n).map(|i| self.point_at(i as f64 / n as f64)).collect() + } } /// A closed loop of segments, each starting where the previous one ends. diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index e9a701fe1a..e44b1c6156 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -30,6 +30,9 @@ encoding_rs = { workspace = true } cadmpeg-core = { workspace = true } cadmpeg-ir = { workspace = true } cadmpeg-registry = { workspace = true } +# Sheet PNGs (src/paper/png.rs). +png = { workspace = true } +tiny-skia = { workspace = true } # A plain fsync where a network share refuses Apple's full flush (src/safe_write.rs); already in # the tree through tempfile. diff --git a/crates/io/src/mutate.rs b/crates/io/src/mutate.rs index f024590707..36756dcee7 100644 --- a/crates/io/src/mutate.rs +++ b/crates/io/src/mutate.rs @@ -551,30 +551,72 @@ outer survives("design JSON with drawings", &mutations(&json, true, 0xd4c), MAX_DRAWINGS, |b| crate::read_design(b).ok().map(|d| d.drawings.len())); } - /// A paper scene's JSON, mutated, read, prepared and written as SVG: an error or a document, - /// never a panic. + /// Mid grey at half alpha, the size asked for: the paper fuzz cases' pictures. + struct Grey; + + impl solidcraft_geom::paper::Pictures for Grey { + fn rows( + &self, + _: u64, + _: &solidcraft_geom::pixel_grid::PixelGrid, + w: solidcraft_geom::pixel_grid::GridWindow, + ) -> std::result::Result, String> { + Ok([128, 128, 128, 128].repeat(w.x1.saturating_sub(w.x0) as usize * w.y1.saturating_sub(w.y0) as usize)) + } + } + + /// A paper scene's JSON with a picture, mutated, read, prepared and written as SVG with its + /// pictures at 25 dpi: an error or a document, never a panic. #[test] fn paper_scenes_survive_mutation_into_svg() { - use solidcraft_geom::paper::{Group, Item, ObjectKind, Scene}; - use solidcraft_geom::{Loop2, Region2, Seg2, Vec2}; + use solidcraft_geom::paper::{Group, Item, ObjectKind, Picture, Scene}; + use solidcraft_geom::{Aabb2, Loop2, Region2, Seg2, Vec2}; let p = Vec2::new; let border = Loop2::polygon(&[p(20.0, 10.0), p(410.0, 10.0), p(410.0, 287.0), p(20.0, 287.0)]); let items = vec![ Item::Stroke { path: border.segs.clone(), closed: true, width_mm: 0.7 }, Item::Stroke { path: vec![Seg2::Arc { center: p(50.0, 50.0), radius: 10.0, start: 0.0, sweep: 4.0 }], closed: false, width_mm: 0.35 }, Item::Fill { region: Region2 { outer: border, holes: vec![] } }, + Item::Picture(Picture { source: 3, rect: Aabb2 { min: p(150.0, 100.0), max: p(250.0, 150.0) }, opacity: 0.8 }), ]; let scene = Scene::new(p(420.0, 297.0), vec![Group { object: 7, kind: ObjectKind::DrawingBorder, items }]); let json = serde_json::to_vec(&scene).unwrap(); let write = |b: &[u8]| -> Option { let scene: Scene = serde_json::from_slice(b).ok()?; let prepared = scene.prepare().ok()?; - crate::paper::paper_svg(&prepared).ok().map(|(bytes, _)| bytes.len()) + crate::paper::paper_svg(&prepared, &Grey, 25.0).ok().map(|(bytes, _)| bytes.len()) }; assert!(write(&json).is_some()); survives("paper scene to SVG", &mutations(&json, true, 0x5a3), usize::MAX, write); } + /// The same scene's JSON with a picture, mutated, read, prepared and written as a 25 dpi sheet + /// PNG: an error or an image, never a panic. + #[test] + fn paper_scenes_survive_mutation_into_png() { + use solidcraft_geom::paper::{Group, Item, ObjectKind, Picture, Scene}; + use solidcraft_geom::{Aabb2, Loop2, Region2, Seg2, Vec2}; + let p = Vec2::new; + let border = Loop2::polygon(&[p(20.0, 10.0), p(410.0, 10.0), p(410.0, 287.0), p(20.0, 287.0)]); + let items = vec![ + Item::Stroke { path: border.segs.clone(), closed: true, width_mm: 0.7 }, + Item::Stroke { path: vec![Seg2::Arc { center: p(50.0, 50.0), radius: 10.0, start: 0.0, sweep: 4.0 }], closed: false, width_mm: 0.35 }, + Item::Fill { region: Region2 { outer: border, holes: vec![Loop2::circle(p(100.0, 100.0), 20.0)] } }, + Item::Picture(Picture { source: 3, rect: Aabb2 { min: p(150.0, 100.0), max: p(250.0, 150.0) }, opacity: 0.8 }), + ]; + let scene = Scene::new(p(420.0, 297.0), vec![Group { object: 7, kind: ObjectKind::DrawingBorder, items }]); + let json = serde_json::to_vec(&scene).unwrap(); + let options = crate::paper::PngOptions { dpi: 25.0, colour: crate::paper::PngColour::Grey }; + let write = |b: &[u8]| -> Option { + let scene: Scene = serde_json::from_slice(b).ok()?; + let prepared = scene.prepare().ok()?; + let mut out = Vec::new(); + crate::paper::paper_png(&prepared, options, &Grey, &mut out).ok().map(|_| out.len()) + }; + assert!(write(&json).is_some()); + survives("paper scene to PNG", &mutations(&json, true, 0x5a4), usize::MAX, write); + } + /// Standard base64 (RFC 4648) of `bytes`, for a data URI. fn base64(bytes: &[u8]) -> String { const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; diff --git a/crates/io/src/paper.rs b/crates/io/src/paper.rs index c4ea5b94d4..43a582ef93 100644 --- a/crates/io/src/paper.rs +++ b/crates/io/src/paper.rs @@ -1,5 +1,7 @@ -//! A sheet's paper scene (`geom::paper`) in a file: SVG first (A3, #4890); DXF, PNG and PDF follow. +//! A sheet's paper scene (`geom::paper`) in a file: SVG (A3, #4890) and sheet PNG (A4); DXF and PDF follow. +mod png; mod svg; +pub use png::{DPI, MAX_PICTURE_DPI, MAX_POINTS, PICTURE_DPI, PngColour, PngOptions, PngReport, paper_png}; pub use svg::{SvgReport, paper_svg}; diff --git a/crates/io/src/paper/png.rs b/crates/io/src/paper/png.rs new file mode 100644 index 0000000000..4f24e71c79 --- /dev/null +++ b/crates/io/src/paper/png.rs @@ -0,0 +1,393 @@ +//! A paper scene as a sheet PNG (#4890, A4): the sheet at its size in pixels at a chosen dpi. +//! +//! - **Geometry** is the scene on the nanometre grid ([`Prepared::on_grid`]), the one SVG writes, so +//! both draw the same joints, closures and merges. Curves are flattened within 1/8 px +//! ([`paper::flatten`]) and tiny-skia is given lines only: its own flattening stops at 64 lines a +//! curve, more than a pixel off on a large arc at a high dpi. +//! - **Paint** as the scene says: black, strokes with butt caps and miter joins (limit 4), fills +//! even-odd. Each line is stroked once, then filled band by band; a line under a pixel wide is +//! drawn as tiny-skia's hairline instead, its coverage scaled by its width, so it never vanishes. +//! - **Bands:** the sheet is drawn [`BAND_BYTES`] of pixels at a time, with a row above and below +//! that isn't written, and streamed into the file, so an A0 sheet at 1200 dpi (1.6 billion +//! pixels) needs no more memory than an A4 one, and the bytes are the same whatever the band. +//! - **Pixels** are the sheet's pixel grid ([`PixelGrid::sheet`]): anchored at its top-left corner +//! and rounded outward, so nothing is clipped (210 mm at 300 dpi is 2481 px) and a side over +//! [`MAX_SIDE_PX`] is refused. +//! - **Pictures** (#5624 S5) are drawn where the scene orders them, into their window of that grid, +//! from a [`Pictures`] provider asked for just the band's rows at the export's dpi, or at +//! [`MAX_PICTURE_DPI`] with each pixel copied when the export is finer. Black and white dithers +//! them; lines stay solid. +//! - **The file** records its dpi (`pHYs`), so it prints at the sheet's size, and is sRGB. + +use std::io::Write; +use std::ops::RangeInclusive; + +use solidcraft_geom::Vec2; +use solidcraft_geom::paper::{self, Grid, GridItem, GridPath, GridReport, Picture, Pictures, Prepared}; +use solidcraft_geom::pixel_grid::{GridWindow, MAX_SIDE_PX, PixelGrid, PixelGridError}; +use tiny_skia::{Color, FillRule, LineCap, LineJoin, Paint, Path, PathBuilder, Pixmap, PremultipliedColorU8, Stroke, Transform}; + +use crate::{IoError, Result}; + +/// The resolutions a sheet PNG may have, in dots per inch. +pub const DPI: RangeInclusive = 25.0..=2400.0; +/// The most points a sheet's lines may flatten to in all. +pub const MAX_POINTS: usize = 1 << 24; +/// The most dots per inch a picture is asked for: above it, a coarser grid's pixels are copied. +pub const MAX_PICTURE_DPI: f64 = 1200.0; +/// The resolutions a picture may be embedded at (SVG, PDF), in dots per inch. +pub const PICTURE_DPI: RangeInclusive = *DPI.start()..=MAX_PICTURE_DPI; +/// The pixels drawn at a time, as RGBA bytes. +const BAND_BYTES: usize = 16 << 20; +/// Rows drawn above and below each band and not written: tiny-skia starts an edge a little +/// differently where a clip cuts it, so no written row sits on a cut and bands change nothing. +const MARGIN: u32 = 1; + +/// How a sheet PNG stores its pixels. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum PngColour { + /// 8-bit greyscale on white paper. + #[default] + Grey, + /// One bit a pixel, black or white, every line at least a pixel wide so none drops out. + BlackWhite, + /// 24-bit colour on white paper. + Rgb, + /// 32-bit colour, the paper transparent. + Transparent, +} + +/// A sheet PNG's resolution and colour. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PngOptions { + pub dpi: f64, + pub colour: PngColour, +} + +impl Default for PngOptions { + fn default() -> Self { + PngOptions { dpi: 300.0, colour: PngColour::Grey } + } +} + +/// What writing a sheet PNG did: the grid's report, the image's width and height in pixels, and +/// whether a picture was dithered to black and white. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct PngReport { + pub grid: GridReport, + pub pixels: [u32; 2], + pub dithered: bool, +} + +/// The scene as a sheet PNG written to `out`, its pictures' pixels from `pictures`. +pub fn paper_png(scene: &Prepared, options: PngOptions, pictures: &dyn Pictures, out: impl Write) -> Result { + write(scene, options, pictures, out, BAND_BYTES) +} + +fn write(scene: &Prepared, options: PngOptions, pictures: &dyn Pictures, out: impl Write, band_bytes: usize) -> Result { + let PngOptions { dpi, colour } = options; + if !DPI.contains(&dpi) { + return Err(IoError::Invalid(format!("a sheet PNG is 25 to 2400 dpi, not {dpi}"))); + } + let grid = sheet_grid(scene.size_mm(), dpi)?; + // Pictures are asked for at most MAX_PICTURE_DPI: on the grid of dpi / k, whose pixels are each + // exactly k × k of the sheet's, since both are anchored at the sheet's top-left corner. + let k = (dpi / MAX_PICTURE_DPI).ceil().max(1.0) as u32; + let asked = if k == 1 { grid } else { sheet_grid(scene.size_mm(), dpi / f64::from(k))? }; + let on = scene.on_grid(Grid::NANOMETRE).map_err(|e| IoError::Invalid(e.to_string()))?; + let px = dpi / 25.4e6; // pixels a nanometre + let pixels = [grid.width, grid.height]; + let mut sheet = Sheet { px, height: on.size.y as f64, tolerance: 0.125 / px, points: 0 }; + let mut shapes = Vec::new(); + for item in on.groups.iter().flat_map(|g| &g.items) { + match item { + GridItem::Stroke { width, path } => { + let mut builder = PathBuilder::new(); + sheet.trace(&mut builder, path)?; + let width = (*width as f64 * px) as f32; + let stroke = Stroke { + width: if colour == PngColour::BlackWhite { width.max(1.0) } else { width }, + miter_limit: 4.0, + line_cap: LineCap::Butt, + line_join: LineJoin::Miter, + dash: None, + }; + // A line under a pixel wide is drawn as tiny-skia's hairline, its coverage scaled by its + // width: its outline would fall between the rows the rasteriser samples and vanish. + let thin = colour != PngColour::BlackWhite && width < 1.0; + shapes.extend( + builder.finish().and_then(|line| if thin { Some(Shape::Thin(line, stroke)) } else { line.stroke(&stroke, 1.0).map(Shape::Fill) }), + ); + } + GridItem::Fill { loops } => { + let mut builder = PathBuilder::new(); + for l in loops { + sheet.trace(&mut builder, l)?; + } + shapes.extend(builder.finish().map(Shape::EvenOdd)); + } + GridItem::Picture(picture) => shapes.extend(grid.window(picture.rect).map(|window| Shape::Picture(*picture, window))), + } + } + let png_error = |e: png::EncodingError| IoError::Invalid(format!("the sheet PNG: {e}")); + let format = match colour { + PngColour::Grey => (png::ColorType::Grayscale, png::BitDepth::Eight), + PngColour::BlackWhite => (png::ColorType::Grayscale, png::BitDepth::One), + PngColour::Rgb => (png::ColorType::Rgb, png::BitDepth::Eight), + PngColour::Transparent => (png::ColorType::Rgba, png::BitDepth::Eight), + }; + let mut header = encoder(out, pixels, format, dpi).write_header().map_err(png_error)?; + let mut stream = header.stream_writer().map_err(png_error)?; + + let width = pixels[0] as usize; + let rows = (band_bytes / (width * 4)).clamp(1, pixels[1] as usize) as u32; + let mut band = Pixmap::new(pixels[0], rows + 2 * MARGIN).ok_or_else(|| IoError::Invalid("the sheet PNG: no room for a band of pixels".into()))?; + // A hairline reaches at most a pixel past its line: into the margin rows, never a written one. + let spans: Vec<(f32, f32)> = shapes + .iter() + .map(|shape| match shape { + Shape::Fill(path) | Shape::EvenOdd(path) | Shape::Thin(path, _) => (path.bounds().top(), path.bounds().bottom()), + Shape::Picture(_, window) => (window.y0 as f32, window.y1 as f32), + }) + .collect(); + let mut paint = Paint { anti_alias: colour != PngColour::BlackWhite, ..Paint::default() }; + paint.set_color(Color::BLACK); + let mut dithered = false; + let mut row = Vec::with_capacity(width * 4); + let mut top = 0; + while top < pixels[1] { + let n = rows.min(pixels[1] - top); + band.fill(if colour == PngColour::Transparent { Color::TRANSPARENT } else { Color::WHITE }); + let shift = Transform::from_translate(0.0, MARGIN as f32 - top as f32); + for (shape, (y0, y1)) in shapes.iter().zip(&spans) { + if *y1 >= top as f32 - MARGIN as f32 && *y0 <= (top + n + MARGIN) as f32 { + match shape { + Shape::Fill(path) => band.fill_path(path, &paint, FillRule::Winding, shift, None), + Shape::EvenOdd(path) => band.fill_path(path, &paint, FillRule::EvenOdd, shift, None), + Shape::Thin(line, stroke) => band.stroke_path(line, &paint, stroke, shift, None), + Shape::Picture(picture, window) => { + let (r0, r1) = (window.y0.max(top), window.y1.min(top + n)); + if r0 < r1 { + let cut = GridWindow { y0: r0, y1: r1, ..*window }; + dithered |= composite(&mut band, top, picture, cut, pictures, &asked, k, colour)?; + } + } + } + } + } + for line in band.pixels().chunks_exact(width).skip(MARGIN as usize).take(n as usize) { + row.clear(); + match colour { + PngColour::Grey => row.extend(line.iter().map(|p| luma(p.red(), p.green(), p.blue()))), + PngColour::BlackWhite => row.extend(line.chunks(8).map(|eight| { + // Most significant bit first; 1 is white. + eight.iter().enumerate().fold(0u8, |byte, (i, p)| byte | (u8::from(luma(p.red(), p.green(), p.blue()) >= 128) << (7 - i))) + })), + PngColour::Rgb => row.extend(line.iter().flat_map(|p| [p.red(), p.green(), p.blue()])), + PngColour::Transparent => row.extend(line.iter().flat_map(|p| { + let c = p.demultiply(); + [c.red(), c.green(), c.blue(), c.alpha()] + })), + } + stream.write_all(&row).map_err(|e| IoError::Invalid(format!("the sheet PNG: {e}")))?; + } + top += n; + } + stream.finish().map_err(png_error)?; + header.finish().map_err(png_error)?; + Ok(PngReport { grid: on.report, pixels, dithered }) +} + +/// The whole sheet's pixel grid at `dpi`, its limit named when a side is too long. +fn sheet_grid(size_mm: Vec2, dpi: f64) -> Result { + PixelGrid::sheet(size_mm, dpi).map_err(|e| { + IoError::Invalid(match e { + PixelGridError::TooLarge { pixels } => { + format!("a sheet PNG side of {pixels:.0} px at {dpi} dpi is over the limit of {MAX_SIDE_PX} px") + } + PixelGridError::Invalid => format!("the sheet PNG: {e}"), + }) + }) +} + +/// A PNG encoder for `pixels` columns and rows of `format` (colour type, bit depth): sRGB, and +/// `dpi` recorded so it prints at its size. +fn encoder(out: W, pixels: [u32; 2], format: (png::ColorType, png::BitDepth), dpi: f64) -> png::Encoder<'static, W> { + let mut encoder = png::Encoder::new(out, pixels[0], pixels[1]); + encoder.set_color(format.0); + encoder.set_depth(format.1); + encoder.set_source_srgb(png::SrgbRenderingIntent::Perceptual); + let per_metre = (dpi / 0.0254).round() as u32; + encoder.set_pixel_dims(Some(png::PixelDimensions { xppu: per_metre, yppu: per_metre, unit: png::Unit::Meter })); + encoder +} + +/// Picture `source`'s pixels in `window` of `grid`, from `pictures`, refused unless they are +/// exactly the size asked for. +fn ask(pictures: &dyn Pictures, source: u64, grid: &PixelGrid, window: GridWindow) -> Result> { + let rgba = pictures.rows(source, grid, window).map_err(|e| IoError::Invalid(format!("picture {source}: {e}")))?; + let expected = window.x1.saturating_sub(window.x0) as usize * window.y1.saturating_sub(window.y0) as usize * 4; + if rgba.len() != expected { + return Err(IoError::Invalid(format!("picture {source}: {} bytes where {expected} were asked for", rgba.len()))); + } + Ok(rgba) +} + +/// Picture `source`'s pixels in `window` of its own `grid` as a PNG file of their own (RGBA, +/// straight alpha), for SVG and PDF to embed: asked for [`BAND_BYTES`] of rows at a time, and +/// refused once the file passes `max_bytes`. +pub(super) fn picture_png(source: u64, grid: &PixelGrid, window: GridWindow, pictures: &dyn Pictures, max_bytes: usize) -> Result> { + let png_error = |e: png::EncodingError| IoError::Invalid(format!("picture {source}: {e}")); + let io_error = |e: std::io::Error| IoError::Invalid(format!("picture {source}: {e}")); + let pixels = [window.x1.saturating_sub(window.x0), window.y1.saturating_sub(window.y0)]; + let mut out = Capped { bytes: Vec::new(), max: max_bytes, refused: false }; + let written = (|| { + let mut header = + encoder(&mut out, pixels, (png::ColorType::Rgba, png::BitDepth::Eight), 25.4 / grid.pitch).write_header().map_err(png_error)?; + let mut stream = header.stream_writer().map_err(png_error)?; + let rows = (BAND_BYTES / (pixels[0] as usize * 4).max(1)).max(1) as u32; + let mut top = window.y0; + while top < window.y1 { + let band = GridWindow { y0: top, y1: window.y1.min(top.saturating_add(rows)), ..window }; + stream.write_all(&ask(pictures, source, grid, band)?).map_err(io_error)?; + top = band.y1; + } + stream.finish().map_err(png_error)?; + header.finish().map_err(png_error) + })(); + match written { + _ if out.refused => Err(IoError::Invalid(format!("picture {source}: its PNG would be over {max_bytes} bytes"))), + Ok(()) => Ok(out.bytes), + Err(e) => Err(e), + } +} + +/// A file in memory that refuses to grow past `max` bytes, and remembers that it did. +struct Capped { + bytes: Vec, + max: usize, + refused: bool, +} + +impl Write for Capped { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + if self.bytes.len() + buf.len() > self.max { + self.refused = true; + return Err(std::io::Error::other("over its limit")); + } + self.bytes.extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +/// The 8 × 8 ordered-dither thresholds, 0 to 63, by row and column. +const BAYER: [[u8; 8]; 8] = [ + [0, 32, 8, 40, 2, 34, 10, 42], + [48, 16, 56, 24, 50, 18, 58, 26], + [12, 44, 4, 36, 14, 46, 6, 38], + [60, 28, 52, 20, 62, 30, 54, 22], + [3, 35, 11, 43, 1, 33, 9, 41], + [51, 19, 59, 27, 49, 17, 57, 25], + [15, 47, 7, 39, 13, 45, 5, 37], + [63, 31, 55, 23, 61, 29, 53, 21], +]; + +/// Picture `picture`'s pixels in `window` (sheet rows inside the band that starts at row `top`) +/// over what the band holds: source-over in sRGB, its alpha times its opacity. They are asked for +/// on `asked`, the sheet's grid or one `k` times coarser, each of whose pixels covers k × k of the +/// sheet's. In black and white each pixel is dithered at its place on the sheet; true when one was. +#[allow(clippy::too_many_arguments)] +fn composite( + band: &mut Pixmap, + top: u32, + picture: &Picture, + window: GridWindow, + pictures: &dyn Pictures, + asked: &PixelGrid, + k: u32, + colour: PngColour, +) -> Result { + let source = picture.source; + let coarse = + GridWindow { x0: window.x0 / k, y0: window.y0 / k, x1: window.x1.div_ceil(k).min(asked.width), y1: window.y1.div_ceil(k).min(asked.height) }; + let columns = coarse.x1.saturating_sub(coarse.x0) as usize; + let rgba = ask(pictures, source, asked, coarse)?; + let width = band.width() as usize; + let pixels = band.pixels_mut(); + for y in window.y0..window.y1 { + for x in window.x0..window.x1 { + let at = ((y / k - coarse.y0) as usize * columns + (x / k - coarse.x0) as usize) * 4; + let (Some(&[r, g, b, a]), Some(under)) = (rgba.get(at..at + 4), pixels.get_mut((y + MARGIN - top) as usize * width + x as usize)) else { + continue; + }; + let alpha = f32::from(a) / 255.0 * picture.opacity as f32; + let out_a = (255.0 * alpha + f32::from(under.alpha()) * (1.0 - alpha)).round() as u8; + let channel = |src: u8, dst: u8| ((f32::from(src) * alpha + f32::from(dst) * (1.0 - alpha)).round() as u8).min(out_a); + let mut out = [channel(r, under.red()), channel(g, under.green()), channel(b, under.blue())]; + if colour == PngColour::BlackWhite { + // The paper is white and opaque here, so the premultiplied channels are the colour. + let threshold = BAYER[(y % 8) as usize][(x % 8) as usize] * 4 + 2; + out = [if luma(out[0], out[1], out[2]) < threshold { 0 } else { 255 }; 3]; + } + *under = PremultipliedColorU8::from_rgba(out[0], out[1], out[2], out_a).unwrap_or(*under); + } + } + Ok(colour == PngColour::BlackWhite) +} + +/// What a band draws for one item. +enum Shape { + /// A stroke's outline, filled non-zero. + Fill(Path), + /// A fill's loops, filled even-odd. + EvenOdd(Path), + /// A line under a pixel wide, stroked as a hairline. + Thin(Path, Stroke), + /// A picture, and the pixels of the sheet it covers. + Picture(Picture, GridWindow), +} + +/// The luma of an sRGB pixel, Rec. 709's weights in 256ths. +fn luma(r: u8, g: u8, b: u8) -> u8 { + ((u32::from(r) * 54 + u32::from(g) * 183 + u32::from(b) * 19 + 128) >> 8) as u8 +} + +/// The sheet in pixels: the grid's nanometres scaled, y flipped to the page's top, curves +/// flattened within the tolerance, the points counted against [`MAX_POINTS`]. +struct Sheet { + /// Pixels a nanometre. + px: f64, + /// The sheet's height, in nanometres. + height: f64, + /// The flattening tolerance, in nanometres. + tolerance: f64, + points: usize, +} + +impl Sheet { + /// `path` onto `builder` in pixels, closed when it is. + fn trace(&mut self, builder: &mut PathBuilder, path: &GridPath) -> Result<()> { + let points = paper::flatten(path, self.tolerance).map_err(|e| IoError::Invalid(e.to_string()))?; + self.points += points.len(); + if self.points > MAX_POINTS { + return Err(IoError::Invalid(format!("the sheet needs over {MAX_POINTS} points at this resolution"))); + } + let at = |p: Vec2| ((p.x * self.px) as f32, ((self.height - p.y) * self.px) as f32); + for (i, p) in points.into_iter().enumerate() { + let (x, y) = at(p); + if i == 0 { builder.move_to(x, y) } else { builder.line_to(x, y) } + } + if path.closed { + builder.close(); + } + Ok(()) + } +} + +#[cfg(test)] +#[path = "png_tests.rs"] +pub(super) mod tests; diff --git a/crates/io/src/paper/png_tests.rs b/crates/io/src/paper/png_tests.rs new file mode 100644 index 0000000000..61a78d3af4 --- /dev/null +++ b/crates/io/src/paper/png_tests.rs @@ -0,0 +1,428 @@ +use std::cell::RefCell; +use std::f64::consts::{FRAC_PI_2, TAU}; + +use solidcraft_geom::paper::{Group, Item, NoPictures, ObjectKind, Scene}; +use solidcraft_geom::{Aabb2, Loop2, Region2, Seg2, Vec2}; + +use super::*; + +fn p(x: f64, y: f64) -> Vec2 { + Vec2::new(x, y) +} + +fn line(a: Vec2, b: Vec2) -> Seg2 { + Seg2::Line { a, b } +} + +/// Anticlockwise from `min`. +fn rectangle(min: Vec2, max: Vec2) -> Loop2 { + Loop2::polygon(&[min, p(max.x, min.y), max, p(min.x, max.y)]) +} + +fn scene(size: Vec2, items: Vec) -> Scene { + Scene::new(size, vec![Group { object: 1, kind: ObjectKind::DrawingBorder, items }]) +} + +/// A 20 × 10 mm sheet: a 1 mm line along y = 5 from x = 2 to 18, and a 6 × 3 mm fill at (2, 1) +/// with a 2 × 1 mm hole at (4, 2). At 254 dpi a millimetre is 10 pixels. +fn small() -> Scene { + scene( + p(20.0, 10.0), + vec![ + Item::Stroke { path: vec![line(p(2.0, 5.0), p(18.0, 5.0))], closed: false, width_mm: 1.0 }, + Item::Fill { region: Region2 { outer: rectangle(p(2.0, 1.0), p(8.0, 4.0)), holes: vec![rectangle(p(4.0, 2.0), p(6.0, 3.0))] } }, + ], + ) +} + +pub(in crate::paper) struct Image { + pub(in crate::paper) info: png::Info<'static>, + /// One byte a sample, rows top down: 1-bit images unpacked to 0 or 255. + pub(in crate::paper) samples: Vec, +} + +impl Image { + fn channels(&self) -> usize { + self.info.color_type.samples() + } + + /// The samples of the pixel at column `x`, row `y` from the top. + pub(in crate::paper) fn at(&self, x: usize, y: usize) -> &[u8] { + let c = self.channels(); + let i = (y * self.info.width as usize + x) * c; + &self.samples[i..i + c] + } +} + +fn bytes(scene: &Scene, options: PngOptions) -> (Vec, PngReport) { + let mut out = Vec::new(); + let report = paper_png(&scene.prepare().unwrap(), options, &NoPictures, &mut out).unwrap(); + (out, report) +} + +pub(in crate::paper) fn read(bytes: &[u8]) -> Image { + let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes.to_vec())); + decoder.set_transformations(png::Transformations::EXPAND); + let mut reader = decoder.read_info().unwrap(); + let mut samples = vec![0; reader.output_buffer_size().unwrap()]; + reader.next_frame(&mut samples).unwrap(); + let info = reader.info().clone(); + if info.bit_depth == png::BitDepth::One { + // EXPAND makes a 1-bit grey sample 0 or 255 a byte. + assert!(samples.iter().all(|s| *s == 0 || *s == 255)); + } + Image { info, samples } +} + +fn image(scene: &Scene, options: PngOptions) -> Image { + read(&bytes(scene, options).0) +} + +const AT_254: PngOptions = PngOptions { dpi: 254.0, colour: PngColour::Grey }; + +/// A sheet is its size at the dpi, rounded outward as the sheet's pixel grid is (nothing is +/// clipped), and says its dpi, so it prints at the sheet's size: 300 dpi greyscale unless asked, +/// sRGB. A side over the grid's limit is refused with the limit named. +#[test] +fn a_sheet_is_its_size_at_the_dpi_and_records_it() { + let a4 = scene(p(210.0, 297.0), vec![]); + assert_eq!(PngOptions::default(), PngOptions { dpi: 300.0, colour: PngColour::Grey }); + let (data, report) = bytes(&a4, PngOptions::default()); + assert_eq!(report.pixels, [2481, 3508]); + let image = read(&data); + assert_eq!((image.info.width, image.info.height), (2481, 3508)); + assert_eq!((image.info.color_type, image.info.bit_depth), (png::ColorType::Grayscale, png::BitDepth::Eight)); + let dims = image.info.pixel_dims.unwrap(); + assert_eq!((dims.xppu, dims.yppu, dims.unit), (11811, 11811, png::Unit::Meter)); + assert!(image.info.srgb.is_some()); + assert!(image.samples.iter().all(|s| *s == 255), "blank paper is white"); + assert_eq!(bytes(&a4, PngOptions { dpi: 25.0, ..PngOptions::default() }).1.pixels, [207, 293]); + assert_eq!(bytes(&scene(p(420.0, 297.0), vec![]), PngOptions::default()).1.pixels, [4961, 3508]); + let a0 = scene(p(1189.0, 841.0), vec![]); + let e = paper_png(&a0.prepare().unwrap(), PngOptions { dpi: 1500.0, colour: PngColour::Grey }, &NoPictures, Vec::new()).unwrap_err(); + assert!(e.to_string().contains("over the limit of 65536 px"), "{e}"); +} + +/// The line and the fill land on their pixels: the line 10 rows deep round row 50 with butt ends, +/// the fill's ring black and its hole white. +#[test] +fn lines_and_fills_land_on_their_pixels() { + let image = image(&small(), AT_254); + assert_eq!((image.info.width, image.info.height), (200, 100)); + let grey = |x, y| image.at(x, y)[0]; + for (x, y, value, what) in [ + (100, 45, 0, "the line's top row"), + (100, 54, 0, "its bottom row"), + (100, 44, 255, "above it"), + (100, 55, 255, "below it"), + (20, 50, 0, "its first column"), + (19, 50, 255, "before its butt end"), + (179, 50, 0, "its last column"), + (180, 50, 255, "after it"), + (30, 75, 0, "the fill's ring"), + (50, 75, 255, "the fill's hole"), + (90, 75, 255, "beside the fill"), + ] { + assert_eq!(grey(x, y), value, "{what}, ({x}, {y})"); + } +} + +/// Each colour: its PNG type, black ink and white (or no) paper. +#[test] +fn each_colour_has_its_type_ink_and_paper() { + for (colour, kind, depth, ink, paper) in [ + (PngColour::Grey, png::ColorType::Grayscale, png::BitDepth::Eight, &[0][..], &[255][..]), + (PngColour::BlackWhite, png::ColorType::Grayscale, png::BitDepth::One, &[0], &[255]), + (PngColour::Rgb, png::ColorType::Rgb, png::BitDepth::Eight, &[0, 0, 0], &[255, 255, 255]), + (PngColour::Transparent, png::ColorType::Rgba, png::BitDepth::Eight, &[0, 0, 0, 255], &[0, 0, 0, 0]), + ] { + let image = image(&small(), PngOptions { dpi: 254.0, colour }); + assert_eq!((image.info.color_type, image.info.bit_depth), (kind, depth), "{colour:?}"); + assert_eq!(image.at(100, 50), ink, "{colour:?}: the line"); + // Either side of its butt end, bits 3 and 4 of a 1-bit row's third byte. + assert_eq!((image.at(19, 50), image.at(20, 50)), (paper, ink), "{colour:?}: the line's start"); + assert_eq!(image.at(100, 20), paper, "{colour:?}: the paper"); + } +} + +/// A line far thinner than a pixel is a light grey in greyscale, and a full black pixel in black +/// and white, so it doesn't drop out, even between two rows' centres; edges are anti-aliased only +/// where there is grey, and black and white inks a pixel when its centre is inside. +#[test] +fn a_hairline_stays_a_pixel_wide_in_black_and_white() { + // 50.25 rows from the top: between the centres of rows 49 and 50. + let hair = scene(p(20.0, 10.0), vec![Item::Stroke { path: vec![line(p(2.0, 4.975), p(18.0, 4.975))], closed: false, width_mm: 0.01 }]); + let column = |colour| { + let image = image(&hair, PngOptions { dpi: 254.0, colour }); + (40..60).map(|y| image.at(100, y)[0]).collect::>() + }; + let grey = column(PngColour::Grey); + assert!(grey.iter().all(|v| *v > 200) && grey.iter().any(|v| *v < 255), "a tenth of a pixel is light grey: {grey:?}"); + let bw = column(PngColour::BlackWhite); + assert_eq!(bw.iter().filter(|v| **v == 0).count(), 1, "one black pixel across: {bw:?}"); + let edge = image(&small(), PngOptions { dpi: 100.0, colour: PngColour::Grey }); + assert!(edge.samples.iter().any(|v| *v != 0 && *v != 255), "greyscale edges are anti-aliased"); + // A bar a fifth of a pixel wide over column 100's centre: grey in greyscale, black in black and white. + let bar = scene(p(20.0, 10.0), vec![Item::Fill { region: Region2 { outer: rectangle(p(10.045, 2.0), p(10.065, 8.0)), holes: vec![] } }]); + let grey = image(&bar, AT_254).at(100, 50)[0]; + assert!(grey > 0 && grey < 255, "under half a pixel is grey: {grey}"); + assert_eq!(image(&bar, PngOptions { dpi: 254.0, colour: PngColour::BlackWhite }).at(100, 50), [0], "its centre is inside"); +} + +/// A closed outline is joined where it starts, like its other corners, not left with two butt ends. +#[test] +fn a_closed_outline_joins_at_its_start() { + let square = rectangle(p(2.0, 2.0), p(8.0, 8.0)).segs; + let outline = scene(p(10.0, 10.0), vec![Item::Stroke { path: square, closed: true, width_mm: 1.0 }]); + let image = image(&outline, AT_254); + // A quarter of a millimetre outside each corner, inside the 1 mm line's mitred corner. + for (x, y, what) in [(17, 82, "the start"), (82, 82, "the second corner"), (82, 17, "the third"), (17, 17, "the fourth")] { + assert_eq!(image.at(x, y), [0], "{what}, ({x}, {y})"); + } +} + +/// Arcs are drawn from lines within 1/8 px: on a 40 mm circle at 600 dpi, every pixel the stroke's +/// middle crosses is black and pixels a pixel outside its 0.5 mm width are white, at 64 angles. +#[test] +fn a_large_circle_is_round_to_the_pixel() { + let c = p(50.0, 50.0); + let circle = scene( + p(100.0, 100.0), + vec![Item::Stroke { path: vec![Seg2::Arc { center: c, radius: 40.0, start: 0.0, sweep: TAU }], closed: true, width_mm: 0.5 }], + ); + let image = image(&circle, PngOptions { dpi: 600.0, colour: PngColour::Grey }); + let per_mm = 600.0 / 25.4; + let pixel = |q: Vec2| image.at((q.x * per_mm) as usize, ((100.0 - q.y) * per_mm) as usize)[0]; + for k in 0..64 { + let towards = Vec2::from_angle(TAU * f64::from(k) / 64.0); + assert_eq!(pixel(c + towards * 40.0), 0, "on the circle at {k}/64"); + for r in [40.0 - 0.25 - 1.5 / per_mm, 40.0 + 0.25 + 1.5 / per_mm] { + assert_eq!(pixel(c + towards * r), 255, "off the circle at {k}/64, r {r}"); + } + } +} + +/// Drawn a few rows at a time or all at once, the file is the same bytes, pictures and all, and +/// each picture row is asked for once; and again the same bytes. +#[test] +fn bands_change_nothing_and_the_bytes_repeat() { + let mut items = small().groups.remove(0).items; + items.push(Item::Stroke { + path: vec![Seg2::Arc { center: p(14.0, 3.0), radius: 2.5, start: 0.3, sweep: 1.5 * FRAC_PI_2 }], + closed: false, + width_mm: 0.35, + }); + items.insert(0, picture(1, p(1.0, 1.0), p(3.0, 2.0), 1.0)); + items.push(picture(2, p(12.0, 5.0), p(16.0, 9.0), 0.7)); + items.push(picture(3, p(8.0, 0.5), p(10.03, 4.5), 1.0)); + let sheet = scene(p(20.0, 10.0), items); + let prepared = sheet.prepare().unwrap(); + // The rows asked for, by source: a band asks only for its own rows of a picture. + let rows_asked = |provider: &Provider| { + let mut rows = [0; 4]; + for (source, _, window) in provider.asked.borrow().iter() { + rows[*source as usize] += window.y1 - window.y0; + } + rows + }; + for colour in [PngColour::Grey, PngColour::BlackWhite, PngColour::Rgb, PngColour::Transparent] { + let options = PngOptions { dpi: 300.0, colour }; + let (whole, once) = (&mut Vec::new(), Provider::default()); + write(&prepared, options, &once, &mut *whole, usize::MAX).unwrap(); + let width = read(whole).info.width as usize; + for band_rows in [1, 3, 7] { + let (rows, banded) = (&mut Vec::new(), Provider::default()); + write(&prepared, options, &banded, &mut *rows, band_rows * width * 4).unwrap(); + assert_eq!(whole, rows, "{colour:?}, {band_rows} rows a band"); + assert_eq!(rows_asked(&banded), rows_asked(&once), "{colour:?}, {band_rows} rows a band: each picture row once"); + } + let again = &mut Vec::new(); + paper_png(&prepared, options, &Provider::default(), &mut *again).unwrap(); + assert_eq!(again, whole, "{colour:?}: the same bytes again"); + } +} + +/// Out of range resolutions, a curve the walk can't take yet, and more points than the cap are +/// refused, never drawn some other way. +#[test] +fn what_cannot_be_drawn_is_refused() { + let prepared_small = small(); + let prepared = prepared_small.prepare().unwrap(); + for dpi in [24.9, 2400.1, 0.0, -300.0, f64::NAN, f64::INFINITY] { + let e = paper_png(&prepared, PngOptions { dpi, colour: PngColour::Grey }, &NoPictures, Vec::new()).unwrap_err(); + assert!(e.to_string().contains("25 to 2400 dpi"), "{dpi}: {e}"); + } + for dpi in [25.0, 2400.0] { + assert!(paper_png(&prepared, PngOptions { dpi, colour: PngColour::Grey }, &NoPictures, Vec::new()).is_ok(), "{dpi}"); + } + let ellipse = scene( + p(20.0, 10.0), + vec![Item::Stroke { path: vec![Seg2::Conic { a: p(0.0, 0.0), apex: p(5.0, 5.0), b: p(10.0, 0.0), w: 0.5 }], closed: false, width_mm: 0.35 }], + ); + let e = paper_png(&ellipse.prepare().unwrap(), PngOptions::default(), &NoPictures, Vec::new()).unwrap_err(); + assert!(e.to_string().contains("not supported yet"), "{e}"); + // 1,000 circles of 100 m at 2400 dpi, each about 19,000 points: over 2^24 in all. A small sheet, + // so a cap that failed would cost seconds, not hours. + let big = Loop2::circle(p(0.0, 0.0), 1e5); + let circles = (0..1000).map(|_| Item::Fill { region: Region2 { outer: big.clone(), holes: vec![] } }).collect(); + let e = + paper_png(&scene(p(20.0, 10.0), circles).prepare().unwrap(), PngOptions { dpi: 2400.0, colour: PngColour::Grey }, &NoPictures, Vec::new()) + .unwrap_err(); + assert!(e.to_string().contains("16777216 points"), "{e}"); +} + +/// Pictures by source: 0 a checkerboard of the asked grid's own pixels, 1 red, 2 blue at half +/// alpha, 3 mid grey, 4 noise that doesn't compress, 666 an error; every request is kept, and +/// `short` drops a byte. The SVG tests use it too. +#[derive(Default)] +pub(in crate::paper) struct Provider { + pub(in crate::paper) asked: RefCell>, + pub(in crate::paper) short: bool, +} + +impl Pictures for Provider { + fn rows(&self, source: u64, grid: &PixelGrid, window: GridWindow) -> std::result::Result, String> { + self.asked.borrow_mut().push((source, *grid, window)); + if source == 666 { + return Err("boom".into()); + } + let mut rows = Vec::new(); + for y in window.y0..window.y1 { + for x in window.x0..window.x1 { + rows.extend(match source { + 0 if (x + y) % 2 == 0 => [0, 0, 0, 255], + 0 => [255, 255, 255, 255], + 1 => [255, 0, 0, 255], + 2 => [0, 0, 255, 128], + 4 => (x.wrapping_mul(2_654_435_761) ^ y.wrapping_mul(40_503)).wrapping_mul(2_246_822_519).to_le_bytes(), + _ => [128, 128, 128, 255], + }); + } + } + if self.short { + rows.pop(); + } + Ok(rows) + } +} + +fn picture(source: u64, min: Vec2, max: Vec2, opacity: f64) -> Item { + Item::Picture(Picture { source, rect: Aabb2 { min, max }, opacity }) +} + +fn pictured(scene: &Scene, options: PngOptions, pictures: &Provider) -> Image { + let mut out = Vec::new(); + paper_png(&scene.prepare().unwrap(), options, pictures, &mut out).unwrap(); + read(&out) +} + +const RGB_254: PngOptions = PngOptions { dpi: 254.0, colour: PngColour::Rgb }; + +/// A picture fills exactly its window of the sheet's grid: a 2 × 1 mm picture 3 mm below the +/// top, from x = 4, is columns 40 to 59 and rows 30 to 39 at 254 dpi, and a fill over the same +/// rectangle covers every one of its pixels, so the two register. Also on a sheet 100.8 pixels +/// high, whose grid is anchored at its top edge as the lines are, not at its bottom. +#[test] +fn a_picture_fills_exactly_its_window_and_registers_with_the_lines() { + for height in [10.0, 10.08] { + let rect = rectangle(p(4.0, height - 4.0), p(6.0, height - 3.0)); + let red = picture(1, p(4.0, height - 4.0), p(6.0, height - 3.0), 1.0); + let image = pictured(&scene(p(20.0, height), vec![red.clone()]), RGB_254, &Provider::default()); + for (x, y, colour, what) in [ + (40, 30, [255, 0, 0], "its top-left pixel"), + (59, 39, [255, 0, 0], "its bottom-right pixel"), + (39, 35, [255, 255, 255], "left of it"), + (60, 35, [255, 255, 255], "right of it"), + (50, 29, [255, 255, 255], "above it"), + (50, 40, [255, 255, 255], "below it"), + ] { + assert_eq!(image.at(x, y), colour, "{height} mm high: {what}, ({x}, {y})"); + } + let covered = Item::Fill { region: Region2 { outer: rect, holes: vec![] } }; + let image = pictured(&scene(p(20.0, height), vec![red, covered]), RGB_254, &Provider::default()); + assert!(image.samples.chunks(3).all(|c| c[1] == c[0]), "{height} mm high: no red left beside the fill's edges"); + } +} + +/// Paint goes on in the scene's order: a fill before a picture is under it, one after it is over it. +#[test] +fn pictures_keep_their_place_in_the_paint_order() { + let red = || picture(1, p(4.0, 6.0), p(6.0, 7.0), 1.0); + let fill = || Item::Fill { region: Region2 { outer: rectangle(p(3.0, 5.0), p(7.0, 8.0)), holes: vec![] } }; + let under = pictured(&scene(p(20.0, 10.0), vec![fill(), red()]), RGB_254, &Provider::default()); + assert_eq!(under.at(50, 35), [255, 0, 0], "the picture over the fill"); + assert_eq!(under.at(35, 35), [0, 0, 0], "the fill around it"); + let over = pictured(&scene(p(20.0, 10.0), vec![red(), fill()]), RGB_254, &Provider::default()); + assert_eq!(over.at(50, 35), [0, 0, 0], "the fill over the picture"); +} + +/// Opacity scales a picture's alpha: red at half over white paper is pink; on transparent paper +/// the pixel keeps the half alpha. +#[test] +fn opacity_scales_the_alpha() { + let half = scene(p(20.0, 10.0), vec![picture(1, p(4.0, 6.0), p(6.0, 7.0), 0.5)]); + assert_eq!(pictured(&half, RGB_254, &Provider::default()).at(50, 35), [255, 128, 128]); + let clear = pictured(&half, PngOptions { dpi: 254.0, colour: PngColour::Transparent }, &Provider::default()); + assert_eq!(clear.at(50, 35), [255, 0, 0, 128]); + let blue = scene(p(20.0, 10.0), vec![picture(2, p(4.0, 6.0), p(6.0, 7.0), 1.0)]); + assert_eq!(pictured(&blue, RGB_254, &Provider::default()).at(50, 35), [127, 127, 255], "its own half alpha"); +} + +/// Up to 1200 dpi a picture is asked for on the sheet's own grid. Above it, on the grid of half +/// the dpi at 2400, each of whose pixels fills 2 × 2 of the sheet's. +#[test] +fn above_1200_dpi_a_coarser_pictures_pixels_are_copied() { + let checks = scene(p(20.0, 10.0), vec![picture(0, p(4.0, 4.0), p(5.0, 5.0), 1.0)]); + for (dpi, k) in [(1200.0, 1), (2400.0, 2)] { + let provider = Provider::default(); + let image = pictured(&checks, PngOptions { dpi, colour: PngColour::Rgb }, &provider); + let sheet = PixelGrid::sheet(p(20.0, 10.0), dpi).unwrap(); + let window = sheet.window(Aabb2 { min: p(4.0, 4.0), max: p(5.0, 5.0) }).unwrap(); + let asked = provider.asked.borrow(); + assert_eq!(asked.len(), 1, "one band, one request"); + assert!((asked[0].1.pitch - sheet.pitch * f64::from(k)).abs() < 1e-12, "{dpi}: asked at {} mm", asked[0].1.pitch); + for y in window.y0..window.y1 { + for x in window.x0..window.x1 { + let black = (x / k + y / k) % 2 == 0; + assert_eq!(image.at(x as usize, y as usize), if black { [0, 0, 0] } else { [255, 255, 255] }, "{dpi}: ({x}, {y})"); + } + } + } +} + +/// In black and white a picture is dithered at its place on the sheet: mid grey is half black, +/// whatever the band; a line over it stays solid; the report says so. +#[test] +fn black_and_white_dithers_pictures_and_keeps_lines_solid() { + // 16 × 16 pixels at 254 dpi, from column 8 and row 8. + let grey = picture(3, p(0.8, 7.6), p(2.4, 9.2), 1.0); + let line = Item::Stroke { path: vec![line(p(0.0, 8.0), p(20.0, 8.0))], closed: false, width_mm: 0.2 }; + let sheet = scene(p(20.0, 10.0), vec![grey, line]); + let options = PngOptions { dpi: 254.0, colour: PngColour::BlackWhite }; + let prepared = sheet.prepare().unwrap(); + let (whole, rows) = (&mut Vec::new(), &mut Vec::new()); + let report = write(&prepared, options, &Provider::default(), &mut *whole, usize::MAX).unwrap(); + write(&prepared, options, &Provider::default(), &mut *rows, 1).unwrap(); + assert_eq!(whole, rows, "bands change nothing"); + assert!(report.dithered); + let image = read(whole); + let black = (8..24).flat_map(|y| (8..24).map(move |x| (x, y))).filter(|&(x, y)| y != 19 && y != 20 && image.at(x, y)[0] == 0).count(); + assert_eq!(black, 14 * 16 / 2, "half of mid grey is black, away from the line"); + assert!((0..200).all(|x| image.at(x, 19)[0] == 0 && image.at(x, 20)[0] == 0), "the line is solid across the picture"); + assert!(!bytes(&small(), options).1.dithered, "nothing to dither"); +} + +/// A provider's pixels are checked: too few bytes, or an error, refuse the PNG with the source +/// named; no provider refuses any picture; a picture off the sheet asks for nothing. +#[test] +fn a_providers_pixels_are_checked() { + let sheet = |source| scene(p(20.0, 10.0), vec![picture(source, p(4.0, 6.0), p(6.0, 7.0), 1.0)]); + let refused = |s: &Scene, provider: &dyn Pictures| paper_png(&s.prepare().unwrap(), RGB_254, provider, Vec::new()).unwrap_err().to_string(); + let short = refused(&sheet(1), &Provider { short: true, ..Provider::default() }); + assert!(short.contains("picture 1: 799 bytes where 800 were asked for"), "{short}"); + assert!(refused(&sheet(666), &Provider::default()).contains("picture 666: boom")); + assert!(refused(&sheet(1), &NoPictures).contains("no pixels for picture 1")); + let provider = Provider::default(); + pictured(&scene(p(20.0, 10.0), vec![picture(1, p(30.0, 30.0), p(40.0, 40.0), 1.0)]), RGB_254, &provider); + assert!(provider.asked.borrow().is_empty()); +} diff --git a/crates/io/src/paper/svg.rs b/crates/io/src/paper/svg.rs index da97e9d7d5..c3b5b08d16 100644 --- a/crates/io/src/paper/svg.rs +++ b/crates/io/src/paper/svg.rs @@ -1,64 +1,71 @@ //! A paper scene as SVG (#4890, A3): one user unit a millimetre, the paper's size in mm, and the //! scene's own curves where SVG has them. //! -//! - **Numbers** are whole nanometres, written as millimetres with up to six decimals: no exponent, no "-0", -//! nothing that depends on the locale. They are also the writer's state, so a joint is written -//! once and a closed path ends exactly on its first point. -//! - **y is flipped** by writing the page height less y, so the file's numbers are page -//! coordinates, with no transform. After the flip an anticlockwise arc turns the negative way, -//! so its sweep flag is 0. -//! - **Curves:** lines `L`, circular arcs `A` in pieces of at most a quarter turn (a half turn's -//! centre is ill-conditioned once its ends are rounded), cubics `C`, parabolas `Q`. Ellipses, -//! hyperbolas and Béziers above degree 3 aren't written yet (A3b): a scene with one is refused. +//! - **Numbers** are the scene on the nanometre grid ([`Prepared::on_grid`], whose rules give the +//! joints, closures and merges), written as millimetres with up to six decimals: no exponent, no +//! "-0", nothing that depends on the locale. +//! - **y is flipped** by writing the page height less y, exact on the grid, so the file's numbers +//! are page coordinates, with no transform. After the flip an anticlockwise arc turns the +//! negative way, so its sweep flag is 0. +//! - **Curves:** lines `L`, circular arcs `A` in the grid's pieces of at most a quarter turn (a half +//! turn's centre is ill-conditioned once its ends are rounded), cubics `C`, parabolas `Q`. //! - **Paint:** strokes `stroke-width`, with butt caps, miter joins and a miter limit of 4 on the //! root; fills black, even-odd. One `` per item, so each stays one object in an editor. //! - **Groups** are ``: object ids repeat and start with a digit, //! so they can't be XML ids. No background: the page stays transparent, as cutters want. -//! -//! What the grid can't express is counted, never an error: a width under half a nanometre is -//! written as one, and a piece ending within 10 nanometres of where the path is goes straight on -//! to the next. +//! - **Pictures** (#5624 S5) are ``s holding a PNG as a data URI, on their own pixel grid +//! ([`Picture::grid`]) at the picture resolution the caller gives, cropped to the pixels on the +//! sheet. Their edges are the grid's, on the same nanometre grid as the lines, so they register. use std::fmt::Write; -use solidcraft_geom::paper::{Item, Prepared, arc_pieces}; -use solidcraft_geom::{Region2, Seg2, Vec2}; +use solidcraft_geom::paper::{Grid, GridItem, GridPath, GridPoint, GridReport, Picture, Pictures, Piece, Prepared}; +use solidcraft_geom::{Aabb2, Vec2}; +use super::png::{PICTURE_DPI, picture_png}; use crate::{IoError, MAX_DRAWING_BYTES, Result}; -/// The largest number written, in millimetres either way: far past any scene's 2e5. -const MAX_NUMBER_MM: f64 = 9e9; -/// Pieces ending this many grid steps or fewer from where the path is are merged (1e-5 mm). -const SLIVER_STEPS: i64 = 10; +/// What writing a scene did that the scene didn't say: the grid's report. +pub type SvgReport = GridReport; -/// What writing a scene did that the scene didn't say. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct SvgReport { - /// Widths under half a nanometre, written as one nanometre. - pub widths_snapped: usize, - /// Pieces too short for the nanometre grid, merged into the next. - pub pieces_merged: usize, -} - -/// A scene's SVG document, and what writing it did. -pub fn paper_svg(scene: &Prepared) -> Result<(Vec, SvgReport)> { - let mut w = Writer { out: String::new(), height: nano(scene.size_mm().y)?, report: SvgReport::default(), at: (0, 0), first: (0, 0) }; - let (width, height) = (number(nano(scene.size_mm().x)?), number(w.height)); +/// A scene's SVG document, its pictures' pixels from `pictures` at `picture_dpi`, and what +/// writing it did. +pub fn paper_svg(scene: &Prepared, pictures: &dyn Pictures, picture_dpi: f64) -> Result<(Vec, SvgReport)> { + if !PICTURE_DPI.contains(&picture_dpi) { + return Err(IoError::Invalid(format!("an SVG's pictures are 25 to 1200 dpi, not {picture_dpi}"))); + } + let on = scene.on_grid(Grid::NANOMETRE).map_err(|e| IoError::Invalid(e.to_string()))?; + let mut w = Writer { out: String::new(), height: on.size.y }; + let (width, height) = (number(on.size.x), number(on.size.y)); w.out.push_str("\n"); let _ = writeln!( w.out, "" ); - for group in scene.groups() { + for group in &on.groups { let kind = serde_json::to_value(group.kind).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(); let _ = writeln!(w.out, "", group.object); for item in &group.items { match item { - Item::Stroke { path, closed, width_mm } => w.stroke(path, *closed, *width_mm)?, - Item::Fill { region } => w.fill(region)?, - Item::Text(_) => return Err(IoError::Invalid("not supported yet: text in an SVG (#4890 B2)".into())), + GridItem::Stroke { width, path } => { + w.out.push_str(" { + w.out.push_str(" { + let sheet = Aabb2 { min: Vec2::new(0.0, 0.0), max: scene.size_mm() }; + if !w.picture(picture, sheet, picture_dpi, pictures)? { + continue; + } + } } + w.out.push_str("\"/>\n"); if w.out.len() > MAX_DRAWING_BYTES { return Err(IoError::Invalid(format!("the SVG would be over {} MiB", MAX_DRAWING_BYTES >> 20))); } @@ -66,13 +73,7 @@ pub fn paper_svg(scene: &Prepared) -> Result<(Vec, SvgReport)> { w.out.push_str("\n"); } w.out.push_str("\n"); - Ok((w.out.into_bytes(), w.report)) -} - -/// A value in millimetres as whole nanometres. -fn nano(v: f64) -> Result { - let n = (v * 1e6).round(); - if n.abs() <= MAX_NUMBER_MM * 1e6 { Ok(n as i64) } else { Err(IoError::Invalid("a number too large (or not a number) for the SVG".into())) } + Ok((w.out.into_bytes(), on.report)) } /// Nanometres written as millimetres onto `out`: the integer part, then up to six decimals @@ -89,7 +90,7 @@ fn push_number(out: &mut String, n: i64) { } } -/// [`push_number`] as its own text, for the numbers written once per document or arc. +/// [`push_number`] as its own text, for the numbers written once per document. fn number(n: i64) -> String { let mut text = String::new(); push_number(&mut text, n); @@ -100,141 +101,87 @@ struct Writer { out: String, /// The page height, in nanometres: y is written as this less y. height: i64, - report: SvgReport, - /// Where the path is, and where its subpath started, on the grid. - at: (i64, i64), - first: (i64, i64), } impl Writer { - /// A scene point on the page's grid. - fn grid(&self, p: Vec2) -> Result<(i64, i64)> { - Ok((nano(p.x)?, self.height - nano(p.y)?)) + /// Picture `picture`'s `` up to its `href`'s closing quote: the pixels of its grid at + /// `dpi` that meet `sheet`. False, with nothing written, when none do. + fn picture(&mut self, picture: &Picture, sheet: Aabb2, dpi: f64, pictures: &dyn Pictures) -> Result { + let source = picture.source; + let grid = picture.grid(dpi).map_err(|e| IoError::Invalid(format!("picture {source}: {e}")))?; + let Some(window) = grid.window(sheet) else { + return Ok(false); + }; + // The window's outer pixel edges, on the lines' grid: x0 + i·p across, y0 + j·p up. + let corner = |x: f64, y: f64| Grid::NANOMETRE.point(Vec2::new(x, y)).map_err(|e| IoError::Invalid(e.to_string())); + let low = corner(grid.column(window.x0).0, grid.row(window.y1 - 1).0)?; + let high = corner(grid.column(window.x1 - 1).1, grid.row(window.y0).1)?; + // Base64 is 4 bytes for every 3. + let room = MAX_DRAWING_BYTES.saturating_sub(self.out.len()) / 4 * 3; + let png = picture_png(source, &grid, window, pictures, room)?; + self.out.push_str(" Result<()> { - let at = self.grid(p)?; + /// A path's `d`: a closed one ends with `Z`, which draws its closing line. + fn path(&mut self, path: &GridPath) { self.out.push('M'); - self.point(at); - (self.at, self.first) = (at, at); - Ok(()) - } - - /// Whether a piece ending at `end` is too short to write: it is merged into the next. A path's - /// last piece (`keep`) has no next, so it is written unless it has no length: an open path - /// ends where it ends, a closed one on its first point exactly. - fn merged(&mut self, end: (i64, i64), keep: bool) -> bool { - if keep { - return end == self.at; - } - let (dx, dy) = (end.0 - self.at.0, end.1 - self.at.1); - let short = dx.abs() <= SLIVER_STEPS && dy.abs() <= SLIVER_STEPS && dx * dx + dy * dy <= SLIVER_STEPS * SLIVER_STEPS; - if short { - self.report.pieces_merged += 1; - } - short - } - - /// One piece, from where the path is: `closing` makes its end the subpath's first point, and - /// `keep` marks the path's last piece ([`Writer::merged`]). - fn piece(&mut self, seg: &Seg2, closing: bool, keep: bool) -> Result<()> { - let end = if closing { self.first } else { self.grid(seg.end())? }; - match *seg { - Seg2::Line { .. } => { - if !self.merged(end, keep) { + self.point(path.start); + for piece in &path.pieces { + match *piece { + Piece::Line { to } => { self.out.push('L'); - self.point(end); - self.at = end; + self.point(to); + } + Piece::Arc { radius, clockwise, to } => { + self.out.push('A'); + push_number(&mut self.out, radius); + self.out.push(' '); + push_number(&mut self.out, radius); + let _ = write!(self.out, " 0 0 {} ", u8::from(clockwise)); + self.point(to); + } + Piece::Cubic { c1, c2, to } => { + self.out.push('C'); + self.point(c1); + self.out.push(' '); + self.point(c2); + self.out.push(' '); + self.point(to); + } + Piece::Quadratic { c, to } => { + self.out.push('Q'); + self.point(c); + self.out.push(' '); + self.point(to); } } - Seg2::Arc { center, radius, start, sweep } => { - let r = number(nano(radius)?); - let flag = u8::from(sweep < 0.0); - let mut ends = arc_pieces(center, radius, start, sweep).peekable(); - while let Some(p) = ends.next() { - let final_piece = ends.peek().is_none(); - let to = if final_piece { end } else { self.grid(p)? }; - if !self.merged(to, keep && final_piece) { - let _ = write!(self.out, "A{r} {r} 0 0 {flag} "); - self.point(to); - self.at = to; - } - } - } - Seg2::Cubic { p1, p2, .. } => { - let (c1, c2) = (self.grid(p1)?, self.grid(p2)?); - self.out.push('C'); - self.point(c1); - self.out.push(' '); - self.point(c2); - self.out.push(' '); - self.point(end); - self.at = end; - } - Seg2::Conic { apex, w, .. } if (w - 1.0).abs() <= 1e-12 => { - let c = self.grid(apex)?; - self.out.push('Q'); - self.point(c); - self.out.push(' '); - self.point(end); - self.at = end; - } - Seg2::Conic { .. } | Seg2::Bezier { .. } => { - return Err(IoError::Invalid("not supported yet: SVG of ellipses, hyperbolas and Béziers above degree 3 (#4890, A3b)".into())); - } } - Ok(()) - } - - /// A path's pieces after its `M`: a closed one ends exactly on its first point, a line back to - /// it as `Z`. - fn pieces(&mut self, path: &[Seg2], closed: bool) -> Result<()> { - let Some((last, body)) = path.split_last() else { return Ok(()) }; - for seg in body { - self.piece(seg, false, false)?; + if path.closed { + self.out.push('Z'); } - if !closed { - return self.piece(last, false, true); - } - if !matches!(last, Seg2::Line { .. }) { - self.piece(last, true, true)?; - } - self.out.push('Z'); - Ok(()) - } - - fn stroke(&mut self, path: &[Seg2], closed: bool, width_mm: f64) -> Result<()> { - let Some(first) = path.first() else { return Ok(()) }; - let mut width = nano(width_mm)?; - if width == 0 { - width = 1; - self.report.widths_snapped += 1; - } - self.out.push_str("\n"); - Ok(()) - } - - fn fill(&mut self, region: &Region2) -> Result<()> { - self.out.push_str("\n"); - Ok(()) } } diff --git a/crates/io/src/paper/svg_tests.rs b/crates/io/src/paper/svg_tests.rs index 9ad8720a8c..291470d4e0 100644 --- a/crates/io/src/paper/svg_tests.rs +++ b/crates/io/src/paper/svg_tests.rs @@ -1,9 +1,12 @@ use std::f64::consts::{FRAC_PI_2, PI, TAU}; -use solidcraft_geom::paper::{Group, ObjectKind, Scene}; -use solidcraft_geom::{Loop2, Region2}; +use solidcraft_doc::canvas::base64_decode; +use solidcraft_geom::paper::{Group, Item, NoPictures, ObjectKind, Picture, Pictures, Scene}; +use solidcraft_geom::pixel_grid::GridWindow; +use solidcraft_geom::{Aabb2, Loop2, Region2, Seg2, Vec2}; use super::*; +use crate::paper::png::tests::{Image, Provider, read}; use crate::{Geom2, read_svg}; fn p(x: f64, y: f64) -> Vec2 { @@ -29,7 +32,7 @@ fn sheet(items: Vec) -> Scene { } fn svg(scene: &Scene) -> (String, SvgReport) { - let (bytes, report) = paper_svg(&scene.prepare().unwrap()).unwrap(); + let (bytes, report) = paper_svg(&scene.prepare().unwrap(), &NoPictures, 300.0).unwrap(); (String::from_utf8(bytes).unwrap(), report) } @@ -95,20 +98,18 @@ fn cubics_and_parabolas_are_written_and_other_conics_refused() { for w in [0.5, 2.0] { let conic = Seg2::Conic { a: p(0.0, 0.0), apex: p(10.0, 10.0), b: p(20.0, 0.0), w }; let scene = sheet(vec![stroke(vec![conic], false, 0.25)]); - let e = paper_svg(&scene.prepare().unwrap()).err().map(|e| e.to_string()).unwrap(); + let e = paper_svg(&scene.prepare().unwrap(), &NoPictures, 300.0).err().map(|e| e.to_string()).unwrap(); assert!(e.contains("not supported yet"), "{w}: {e}"); } } /// Numbers are whole nanometres: no exponent, no "-0", trailing zeros trimmed; a width under half -/// a nanometre is written as one and reported. +/// a nanometre is written as one and reported (the grid's own rounding is `geom`'s test). #[test] fn numbers_are_whole_nanometres() { for (n, text) in [(0, "0"), (1, "0.000001"), (-1, "-0.000001"), (1_500_000, "1.5"), (-420_000_000, "-420"), (123_456_789, "123.456789")] { assert_eq!(number(n), text); } - assert_eq!((nano(-0.0).unwrap(), nano(4e-7).unwrap(), nano(6e-7).unwrap()), (0, 0, 1)); - assert!(nano(f64::NAN).is_err() && nano(1e10).is_err()); let thin = sheet(vec![stroke(vec![line(p(0.0, 0.0), p(1.0, 0.0))], false, 1e-9)]); let (text, report) = svg(&thin); assert!(text.contains("stroke-width=\"0.000001\""), "{text}"); @@ -200,6 +201,133 @@ fn text_is_refused_by_name_until_the_writer_learns_it() { rtl: false, }; let scene = Scene { fonts: vec![font], ..sheet(vec![Item::Text(run)]) }; - let refusal = paper_svg(&scene.prepare().unwrap()).unwrap_err().to_string(); + let refusal = paper_svg(&scene.prepare().unwrap(), &NoPictures, 300.0).unwrap_err().to_string(); assert!(refusal.contains("not supported yet: text in an SVG"), "{refusal}"); } + +fn picture(source: u64, min: Vec2, max: Vec2, opacity: f64) -> Item { + Item::Picture(Picture { source, rect: Aabb2 { min, max }, opacity }) +} + +fn pictured(scene: &Scene, dpi: f64, pictures: &Provider) -> String { + String::from_utf8(paper_svg(&scene.prepare().unwrap(), pictures, dpi).unwrap().0).unwrap() +} + +/// The ` Vec<&str> { + text.match_indices(" 1, "{} bands", asked.len()); + let mut top = 0; + for (_, _, w) in asked.iter() { + assert_eq!((w.x0, w.x1, w.y0), (0, 4200, top)); + top = w.y1; + } + assert_eq!(top, 1100); + assert_eq!(embedded(&text).info.height, 1100); +} + +/// What can't be embedded is refused with its limit or its source named, never shrunk: the +/// picture resolution outside 25 to 1200 dpi, a side over 65,536 px, a PNG past the room left in +/// the file, a provider's short rows or its error, and no provider at all. +#[test] +fn what_cannot_be_embedded_is_refused() { + let blank = sheet(vec![]); + let empty = blank.prepare().unwrap(); + for dpi in [24.9, 1200.1, f64::NAN] { + let e = paper_svg(&empty, &NoPictures, dpi).unwrap_err().to_string(); + assert!(e.contains("25 to 1200 dpi"), "{dpi}: {e}"); + } + for dpi in [25.0, 1200.0] { + assert!(paper_svg(&empty, &NoPictures, dpi).is_ok(), "{dpi}"); + } + let refused = |s: &Scene, dpi: f64, provider: &dyn Pictures| paper_svg(&s.prepare().unwrap(), provider, dpi).unwrap_err().to_string(); + let wide = sheet(vec![picture(1, p(0.0, 0.0), p(1400.0, 10.0), 1.0)]); + assert!(refused(&wide, 1200.0, &Provider::default()).contains("over the limit of 65536 px")); + let small = sheet(vec![picture(1, p(4.0, 6.0), p(6.0, 7.0), 1.0)]); + let short = refused(&small, 254.0, &Provider { short: true, ..Provider::default() }); + assert!(short.contains("picture 1: 799 bytes where 800 were asked for"), "{short}"); + assert!(refused(&sheet(vec![picture(666, p(4.0, 6.0), p(6.0, 7.0), 1.0)]), 254.0, &Provider::default()).contains("picture 666: boom")); + assert!(refused(&small, 254.0, &NoPictures).contains("no pixels for picture 1")); + // 40 × 40 px of noise is over 6 KB as a PNG: refused at 1000 bytes, written with room. + let grid = Picture { source: 4, rect: Aabb2 { min: p(0.0, 0.0), max: p(4.0, 4.0) }, opacity: 1.0 }.grid(254.0).unwrap(); + let window = GridWindow { x0: 0, y0: 0, x1: 40, y1: 40 }; + let e = crate::paper::png::picture_png(4, &grid, window, &Provider::default(), 1000).unwrap_err().to_string(); + assert!(e.contains("picture 4: its PNG would be over 1000 bytes"), "{e}"); + assert!(crate::paper::png::picture_png(4, &grid, window, &Provider::default(), 1 << 20).unwrap().len() > 6000); +}