Drawing views: shaded snapshots and their pictures' parts (#5624, W17b S4) (#9798)

* Paper scene: one walk onto a writer's number grid, for every writer (#4890, C4)

The SVG writer kept its own number grid: each coordinate whole nanometres, every joint written once,
a closed path ending exactly on its first point, slivers merged into the next piece and a path's
last piece kept. PDF, PNG and the Drawing workspace's canvas need the same rules, so they move into
`geom::paper::grid`, and the SVG writer only formats what the walk gives it.

- `Grid`: `per_mm` steps a millimetre. `Grid::NANOMETRE` (six decimals of a millimetre) is SVG's.
  `Grid::new` takes 1 to 1e9; a power of two keeps every number exact as a binary float, which a
  writer of 32-bit numbers such as PDF can use.
- `Prepared::on_grid(grid) -> OnGrid`: each group's items as paths of `Piece`s (lines, arcs of at
  most a quarter turn, cubics, parabolas), y up as in the scene, a closed path's closing line left
  to the writer's close, and a report of widths snapped and pieces merged. Slivers are measured in
  the grid's own steps, so a coarser grid merges more.
- The SVG writer flips y on the whole numbers it's given and writes the same bytes as before.
  `SvgReport` is the grid's report.
- Ellipses, hyperbolas and Béziers above degree 3 are still refused (A3b), now by the walk.

DXF stays on its own number rule for now: it writes exact entities (ELLIPSE, SPLINE) and is being
reworked under #5318 to #5321; whether its polylines take a grid is decided there.

Tests: every SVG test unchanged, bytes included; the grid's rounding and its bounds; the A3 border
on the grid (y up, closing line left out, fill loops closed); slivers in the grid's own steps, with
the merge boundary both ways (10 and 11 steps, a 6-8 and a 7-8 step); an arc's quarters with their
radius and turn in steps; a width under half a step made one step; an ellipse refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Paper scene: a sheet PNG at a chosen dpi and colour (#4890 A4)

A new writer, io::paper::paper_png, that reads the shared walk on the
nanometre grid, the one the SVG writer uses, so a PNG draws the same joints,
closures and merges. Neither the walk nor the SVG writer changes.

- Curves are flattened within 1/8 px by geom::paper::flatten, which turns
  each walked piece back into its Seg2 (an arc's centre from its ends, radius
  and direction) and takes Seg2::chords' steps. chords is the bound
  Seg2::polyline already used (the chord's bulge for arcs, Wang's bound for
  the rest), moved out of polyline unchanged so both read one rule.
  polyline itself doesn't fit: it floors an arc's tolerance at a millionth of
  its radius and caps a piece at 4096 steps, coarser than asked without a
  word (0.1 mm, 9 px, on a 100 m arc at 2400 dpi). Over 2^20 points a path,
  or 2^24 a sheet, is refused instead. tiny-skia only gets lines: its own
  flattening stops at 64 lines a curve, more than a pixel off on a large arc
  at a high dpi.
- 25 to 2400 dpi (300 by default). 8-bit greyscale (the default), 1-bit
  black and white (no anti-aliasing, every line at least a pixel wide), 24-bit
  colour, or 32-bit colour with transparent paper.
- The file records its dpi (pHYs) and sRGB, so it prints at the sheet's size.
- Each line is stroked once, then the sheet is drawn in bands of 16 MiB of
  pixels streamed into the file, so memory doesn't grow with the sheet. Each
  band also draws a row above and below that isn't written: tiny-skia starts
  an edge a little differently where a clip cuts it (one subsample, 16
  levels, in a few pixels), so no written row sits on a cut and the bytes are
  the same whatever the band.
- A line under a pixel wide (in greyscale and colour) is drawn as tiny-skia's
  hairline, its coverage scaled by its width: its stroked outline would fall
  between the rows the rasteriser samples and vanish.

Tests: the pixel size and pHYs of A4 at 300 and 25 dpi; a line's and a fill's
pixels at 254 dpi (butt ends, the fill's hole); each colour's PNG type, ink
and paper; a hairline between two rows' centres that is light grey in
greyscale and stays one pixel in black and white, and a bar a fifth of a pixel
wide that black and white inks by its centre; a closed outline joined at its
start; a 40 mm circle round to the pixel at 600 dpi; the same bytes drawn 1, 3
or 7 rows at a time or all at once, and again; the refusals; the flattener's
bounds and counts, and its point cap at the exact boundary; the scene-JSON
fuzz through the PNG writer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Geom: the pixel grid of a paper rectangle (#5624, W17b S1)

One rule for where a picture's pixels lie on paper, shared by the picture renderer and the writers
that place it: anchored at the rectangle's lower-left corner, rounded outward (W = ceil(w / p - 1e-9)
with p = 25.4 / dpi), so the overhang is at the top and the right; rows run top-down. At most
65,536 px a side; nothing caps the total, as pictures are drawn in tiles and strips.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Paper scene: pictures, and a sheet's pixel grid and windows (#5624, W17b S3; #4890 A9)

A picture on a sheet is `Item::Picture`: the rectangle it sits in, the name of its pixels
(`source`, opaque as a group's object id is) and its opacity. The pixels stay out of the scene: a
writer asks a `Pictures` provider for them on the grid it draws on. A PNG at 1200 dpi then gets a
shaded view drawn at 1200 dpi, not one stretched, and a scene stays small, plain data.

The pixel grid gains the two rules a sheet needs, beside the rectangle's own grid:
- `PixelGrid::sheet`: the whole sheet's grid, anchored at its top-left corner, as an image of
  the sheet is drawn.
- `PixelGrid::window`: the columns and rows a rectangle meets; a side on a pixel edge takes no
  sliver beyond it. A picture drawn into its window of the sheet grid is placed by copying whole
  pixels.

`Picture::grid` is a picture's own grid at a dpi, for SVG and PDF to embed. `Scene::validate`
counts a picture as one record. It refuses a rectangle outside the coordinate bound or without
area, and an opacity that isn't above 0 and at most 1. `Aabb2` is read and written as
`{min, max}`.

On the walk (C4), which S3 follows here: `GridItem::Picture` passes a picture through with its rectangle in
millimetres, since the pixel grid places its pixels, and the SVG and PNG writers refuse it ("not supported yet:
pictures (A9)") until their own slices. `GridItem`, `GridGroup` and `OnGrid` are `PartialEq` only, as a
picture holds numbers that aren't `Eq`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Paper scene: pictures in a sheet PNG (#5624, W17b S5)

The sheet PNG writer draws the scene's pictures (S3's Item::Picture) where
the scene orders them, between its lines and fills, from a Pictures provider.

- The PNG is now the sheet's pixel grid (PixelGrid::sheet, S1): anchored at
  the sheet's top-left corner and rounded outward, so nothing is clipped.
  210 mm at 300 dpi is 2481 px (it was 2480, rounded to the nearest). A side
  over 65,536 px is refused and the refusal names the limit ("a sheet PNG
  side of ... px at ... dpi is over the limit of 65536 px"); an A0 sheet
  fits up to about 1400 dpi.
- A picture fills exactly its window of that grid, so it registers with the
  lines drawn over the same rectangle. Each band asks the provider for just
  its own rows, as straight-alpha sRGB RGBA, and the bytes are checked: too
  few or too many, or the provider's error, refuse the PNG with the picture's
  source named. paper::NoPictures refuses every picture.
- Pictures are asked for at most MAX_PICTURE_DPI (1200): above it, on the
  grid k = ceil(dpi / 1200) times coarser, whose pixels each fill exactly
  k x k of the sheet's (both grids share the top-left anchor), so a 2400 dpi
  export doesn't ask a renderer for four times the pixels.
- Source-over in sRGB, the picture's alpha times its opacity; transparent
  paper keeps the alpha. In black and white each picture pixel is dithered
  with an 8 x 8 ordered (Bayer) matrix at its place on the sheet, so bands
  don't show; lines stay solid. PngReport says whether anything was
  dithered.
- In the writer a picture is one more shape a band draws, beside A4's
  outlines, fills and hairlines; the walk's picture arm passes pictures
  through, and the SVG writer still refuses them (A9).

Tests: a picture's exact window and its register with a fill over the same
rectangle, also on a sheet 100.8 px high (the grid anchored at its top);
pictures keep their place in the paint order; opacity scales the alpha (on
white and transparent paper, and the picture's own half alpha); above 1200
dpi a coarser picture's pixels are each copied 2 x 2; black and white
dithers half of mid grey and keeps a line over it solid, whatever the band;
a provider's short rows, its error and no provider are refused, and a
picture off the sheet asks for nothing; the bands test draws three pictures
1, 3 or 7 rows at a time, the same bytes and each picture row asked for
once; the sheet sizes on the outward grid and the 65,536 px refusal; the PNG
fuzz with a picture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Paper scene: pictures in a sheet SVG (#5624, W17b S5)

A picture is an <image> holding a PNG as a data URI, on the picture's own
pixel grid (Picture::grid) at the picture resolution the caller gives,
25 to 1200 dpi: paper_svg(scene, pictures, picture_dpi), the same shape as
paper_png. Only its window over the sheet is embedded and asked for, so it
never costs more pixels than the sheet holds; one off the sheet is left out.
Its x, y, width and height are the grid's own pixel edges put on the
nanometre grid every line is on, so a line on its edge has its number.
Opacity below 1 is written, in millionths as lengths are.

The PNG writer gives the embed its encoder settings (sRGB, the dpi
recorded) and its provider check, now one function each, and streams the
picture a band of rows at a time, refused once it passes the room left
under the file's 64 MiB.

Tests: the 1182 x 591 px grid at 300 dpi and its edges, a line on the
edge, paint order and opacity, the crop and the picture off the sheet, the
bands, and each refusal; the SVG fuzz case gains a picture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Drawing views: shaded snapshots and their pictures' parts (#5624, W17b S4)

drawing.view.snapshot takes `shaded` as a display or reference style and,
when a picture is drawn, says which: picture {pixels, dpi, rect_mm}, the
boundary's pixel grid at the sheet's dpi or 1200, the lower, since lines
may export finer. It renders nothing. A shaded part's own lines are left
out of the reply, as its picture draws it; it still hides what lies behind
it. shaded_visible and shaded_visible_hidden stay refused until the view's
exact lines (W06), and shaded with `scene` (and on a sheet) until the
paper scene draws pictures.

- engine::drawing_picture: the pictures a view draws, bottom first, and how
  each body takes part in each (painted, depth only, left out). The model
  always hides reference parts behind it; reference parts hide the model
  only when reference_hides_model is on; shaded reference parts that don't
  get a picture of their own under the model's, so they never cover it.
- product_scene::extract_where: the scene for the bodies a caller picks;
  extract keeps the browser's eye, a drawing draws every body.

Tests: every style and hide case, reference parts through a subassembly
and a part, the picture's size at 300, 1200 and 2400 dpi, a view with
nothing shaded, the hidden body in the filtered scene, and which lines a
shaded model or shaded reference parts leave.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
CamWight
2026-11-01 19:01:04 +00:00
committed by GitHub
parent a4c9bc380f
commit 4060069fcd
32 changed files with 2246 additions and 230 deletions
Generated
+2
View File
@@ -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",
]
+4 -1
View File
@@ -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<Value> {
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) => {
@@ -19,7 +19,7 @@ fn refused(s: &mut Session, p: Value) -> String {
fn scene_svg(s: &Session, sheet: u64) -> Vec<u8> {
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
+22 -5
View File
@@ -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::<SnapshotResult>)
.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<Value> {
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<Value> {
} else {
None
};
let flags: Vec<bool> = 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<Value> {
},
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<Scale> {
})
}
/// 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<PictureResult> {
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()
+65 -7
View File
@@ -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::<SnapshotResult>();
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<bool> { reply["lines"].as_array().unwrap().iter().map(|l| l["reference"].as_bool().unwrap()).collect() };
let bodies = |reply: &Value| -> std::collections::BTreeSet<String> {
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::<std::collections::BTreeSet<_>>(), 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<solidcraft_geom::Seg2>, 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("<g data-object=\"0\" data-kind=\"view\">").count(), svg.matches("<path").count());
assert_eq!(counts, (1, strokes.len()), "{svg}");
+48
View File
@@ -0,0 +1,48 @@
//! A drawing view's shaded pictures (W17b S4, #5624): which pictures a view draws and how each body
//! takes part in each, from its two visual styles and whether reference parts hide the model.
//!
//! - The model is drawn when its style is shaded, reference parts
//! ([`crate::drawing_view::Shown::reference`]) when theirs is.
//! - The model always hides reference parts behind it; reference parts hide the model only when
//! `reference_hides_model` is on.
//! - Shaded reference parts that don't hide the model get a picture of their own, drawn first,
//! under the model's, which leaves them out: so they never cover it.
use solidcraft_render::VisualStyle;
/// How a body takes part in one picture.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Part {
/// Drawn in its own colours.
Painted,
/// Hides what lies behind it and draws nothing.
DepthOnly,
/// Not in the picture.
Left,
}
/// The pictures a view draws, bottom first, each as one [`Part`] for every body, where
/// `is_reference[i]` says whether body `i` is a reference part. Empty when nothing shaded is there.
pub fn pictures(display: VisualStyle, reference: VisualStyle, hides_model: bool, is_reference: &[bool]) -> Vec<Vec<Part>> {
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::<Vec<_>>();
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;
@@ -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::<Vec<Part>>::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::<Vec<Part>>::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::<Vec<bool>>();
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::<Vec<bool>>();
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::<Vec<bool>>(), "the plate alone");
}
+3 -2
View File
@@ -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(&region.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() }
}
+12 -2
View File
@@ -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<usize> = 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.
+7 -3
View File
@@ -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)")
+2 -2
View File
@@ -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]);
+1
View File
@@ -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;
+11 -2
View File
@@ -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<ProductScene> {
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<ProductScene> {
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);
}
+11
View File
@@ -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<SceneResult>,
/// The view's picture, when something in it is shaded.
#[serde(skip_serializing_if = "Option::is_none")]
pub picture: Option<PictureResult>,
pub source: SourceResult,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
@@ -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}})),
]);
}
+4 -1
View File
@@ -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,
+1
View File
@@ -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(&region.outer.segs), true, 0.0),
Item::Text(_) => panic!("a border has no text"),
Item::Picture(_) => panic!("a sheet draws no pictures"),
})
.collect()
}
+1
View File
@@ -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;
+67 -2
View File
@@ -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<PixelGrid, PixelGridError> {
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<Vec<u8>, 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<Vec<u8>, 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 {
+1
View File
@@ -23,6 +23,7 @@ fn drawn(item: &Item) -> (Vec<[i64; 4]>, bool, Option<f64>) {
Item::Stroke { path, closed, width_mm } => (ends(path), *closed, Some(*width_mm)),
Item::Fill { region } => (ends(&region.outer.segs), true, None),
Item::Text(_) => panic!("an annotation draws no text"),
Item::Picture(picture) => panic!("an annotation draws no picture: {picture:?}"),
}
}
+164
View File
@@ -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<Vec<Vec2>> {
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<usize> {
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(&parabola, 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");
}
}
+254
View File
@@ -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<Grid> {
(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<i64> {
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<GridPoint> {
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<Piece>,
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<GridPath> },
/// 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<GridItem>,
}
/// 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<GridGroup>,
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<OnGrid> {
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<Option<GridPath>> {
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<Piece>,
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(())
}
}
+137 -1
View File
@@ -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::<Vec<_>>();
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::<Scene>(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::<Scene>(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")));
}
+218
View File
@@ -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<PixelGrid, PixelGridError> {
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<PixelGrid, PixelGridError> {
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<GridWindow> {
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));
}
}
+21 -21
View File
@@ -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<Vec2> {
/// 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<Vec2> {
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.
+3
View File
@@ -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.
+47 -5
View File
@@ -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<Vec<u8>, 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<usize> {
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<usize> {
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+/";
+3 -1
View File
@@ -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};
+393
View File
@@ -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<f64> = 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<f64> = *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<PngReport> {
write(scene, options, pictures, out, BAND_BYTES)
}
fn write(scene: &Prepared, options: PngOptions, pictures: &dyn Pictures, out: impl Write, band_bytes: usize) -> Result<PngReport> {
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> {
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<W: Write>(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<Vec<u8>> {
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<Vec<u8>> {
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<u8>,
max: usize,
refused: bool,
}
impl Write for Capped {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
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<bool> {
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;
+428
View File
@@ -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<Item>) -> 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<u8>,
}
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<u8>, 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::<Vec<u8>>()
};
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<Vec<(u64, PixelGrid, GridWindow)>>,
pub(in crate::paper) short: bool,
}
impl Pictures for Provider {
fn rows(&self, source: u64, grid: &PixelGrid, window: GridWindow) -> std::result::Result<Vec<u8>, 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());
}
+112 -165
View File
@@ -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 `<path>` per item, so each stays one object in an editor.
//! - **Groups** are `<g data-object="…" data-kind="…">`: 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 `<image>`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<u8>, 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<u8>, 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("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
let _ = writeln!(
w.out,
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{width}mm\" height=\"{height}mm\" viewBox=\"0 0 {width} {height}\" \
fill=\"none\" stroke=\"#000\" stroke-linecap=\"butt\" stroke-linejoin=\"miter\" stroke-miterlimit=\"4\">"
);
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, "<g data-object=\"{}\" data-kind=\"{kind}\">", 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("<path stroke-width=\"");
push_number(&mut w.out, *width);
w.out.push_str("\" d=\"");
w.path(path);
}
GridItem::Fill { loops } => {
w.out.push_str("<path fill=\"#000\" stroke=\"none\" fill-rule=\"evenodd\" d=\"");
loops.iter().for_each(|l| w.path(l));
}
GridItem::Picture(picture) => {
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<u8>, SvgReport)> {
w.out.push_str("</g>\n");
}
w.out.push_str("</svg>\n");
Ok((w.out.into_bytes(), w.report))
}
/// A value in millimetres as whole nanometres.
fn nano(v: f64) -> Result<i64> {
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 `<image>` 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<bool> {
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("<image x=\"");
push_number(&mut self.out, low.x);
self.out.push_str("\" y=\"");
push_number(&mut self.out, self.height - high.y);
self.out.push_str("\" width=\"");
push_number(&mut self.out, high.x - low.x);
self.out.push_str("\" height=\"");
push_number(&mut self.out, high.y - low.y);
self.out.push_str("\" preserveAspectRatio=\"none\"");
if picture.opacity < 1.0 {
// Written as millionths, as lengths are.
self.out.push_str(" opacity=\"");
push_number(&mut self.out, (picture.opacity * 1e6).round() as i64);
self.out.push('"');
}
self.out.push_str(" href=\"data:image/png;base64,");
self.out.push_str(&solidcraft_doc::canvas::base64_encode(&png));
Ok(true)
}
fn point(&mut self, (x, y): (i64, i64)) {
push_number(&mut self.out, x);
fn point(&mut self, p: GridPoint) {
push_number(&mut self.out, p.x);
self.out.push(' ');
push_number(&mut self.out, y);
push_number(&mut self.out, self.height - p.y);
}
/// Starts a subpath at `p`.
fn move_to(&mut self, p: Vec2) -> 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("<path stroke-width=\"");
push_number(&mut self.out, width);
self.out.push_str("\" d=\"");
self.move_to(first.start())?;
self.pieces(path, closed)?;
self.out.push_str("\"/>\n");
Ok(())
}
fn fill(&mut self, region: &Region2) -> Result<()> {
self.out.push_str("<path fill=\"#000\" stroke=\"none\" fill-rule=\"evenodd\" d=\"");
for l in region.loops() {
let Some(first) = l.segs.first() else { continue };
self.move_to(first.start())?;
self.pieces(&l.segs, true)?;
}
self.out.push_str("\"/>\n");
Ok(())
}
}
+136 -8
View File
@@ -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<Item>) -> 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 `<image …` elements of `text`, each up to its `href`.
fn images(text: &str) -> Vec<&str> {
text.match_indices("<image ").map(|(i, _)| &text[i..i + text[i..].find(" href=").unwrap()]).collect()
}
/// The PNG inside the document's first `<image>`.
fn embedded(text: &str) -> Image {
let start = text.find("href=\"data:image/png;base64,").unwrap() + 28;
read(&base64_decode(text[start..].split('"').next().unwrap()).unwrap())
}
/// A 100 × 50 mm picture at 300 dpi is a PNG of its own grid, 1182 × 591 px (100.076 × 50.038 mm),
/// anchored at its lower-left corner (20.3, 30.7), so its top is at 297 − 80.738 = 216.262 on the
/// page: straight-alpha RGBA, sRGB, 300 dpi recorded, the provider's pixels as they were given.
#[test]
fn a_picture_is_a_png_of_its_own_grid() {
let rect = Aabb2 { min: p(20.3, 30.7), max: p(120.3, 80.7) };
let provider = Provider::default();
let text = pictured(&sheet(vec![picture(0, rect.min, rect.max, 1.0)]), 300.0, &provider);
assert_eq!(images(&text), ["<image x=\"20.3\" y=\"216.262\" width=\"100.076\" height=\"50.038\" preserveAspectRatio=\"none\""]);
let grid = Picture { source: 0, rect, opacity: 1.0 }.grid(300.0).unwrap();
assert_eq!(provider.asked.borrow().as_slice(), [(0, grid, GridWindow { x0: 0, y0: 0, x1: 1182, y1: 591 })]);
let image = embedded(&text);
assert_eq!((image.info.width, image.info.height, image.info.color_type), (1182, 591, png::ColorType::Rgba));
assert!(image.info.srgb.is_some());
assert_eq!(image.info.pixel_dims.map(|d| d.xppu), Some(11811), "300 dpi");
for (x, y) in [(0, 0), (1, 0), (1181, 590), (600, 301)] {
assert_eq!(image.at(x, y), if (x + y) % 2 == 0 { [0, 0, 0, 255] } else { [255, 255, 255, 255] }, "({x}, {y})");
}
}
/// A line along a picture's edge is written with the image's own number: they register.
#[test]
fn a_line_on_a_pictures_edge_has_the_images_number() {
// 100.076 mm wide at 300 dpi: the right edge is at 120.376.
let edge = stroke(vec![line(p(120.376, 30.7), p(120.376, 80.738))], false, 0.35);
let text = pictured(&sheet(vec![picture(1, p(20.3, 30.7), p(120.3, 80.7), 1.0), edge]), 300.0, &Provider::default());
assert!(images(&text)[0].contains("x=\"20.3\" y=\"216.262\" width=\"100.076\""), "{:?}", images(&text));
assert!(text.contains("d=\"M120.376 266.3L120.376 216.262\""), "the line ends on the image's top and right edges");
}
/// An image goes where the scene orders it, between the paths before and after it; opacity below 1
/// is written, in millionths as lengths are, and 1 is left out.
#[test]
fn an_image_keeps_its_place_and_its_opacity() {
let fill = Item::Fill { region: Region2 { outer: rectangle(p(3.0, 5.0), p(7.0, 8.0)), holes: vec![] } };
let edge = stroke(vec![line(p(0.0, 0.0), p(10.0, 0.0))], false, 0.35);
let text = pictured(&sheet(vec![fill, picture(1, p(4.0, 6.0), p(6.0, 7.0), 0.3333333), edge]), 254.0, &Provider::default());
let (filled, image, stroked) = (text.find("fill-rule").unwrap(), text.find("<image").unwrap(), text.find("stroke-width").unwrap());
assert!(filled < image && image < stroked, "{:?}", (filled, image, stroked));
assert!(images(&text)[0].ends_with(" opacity=\"0.333333\""), "{:?}", images(&text));
let text = pictured(&sheet(vec![picture(1, p(4.0, 6.0), p(6.0, 7.0), 1.0)]), 254.0, &Provider::default());
assert!(!text.contains("opacity"));
}
/// Only the pixels on the sheet are embedded and asked for: a picture over the A3 sheet's top-right
/// corner is cut to its 100 × 170 px there at 254 dpi; one off the sheet is left out unasked.
#[test]
fn only_the_pixels_on_the_sheet_are_embedded() {
let provider = Provider::default();
let text = pictured(&sheet(vec![picture(1, p(410.0, 280.0), p(430.0, 300.0), 1.0)]), 254.0, &provider);
assert_eq!(images(&text), ["<image x=\"410\" y=\"0\" width=\"10\" height=\"17\" preserveAspectRatio=\"none\""]);
assert_eq!(provider.asked.borrow()[0].2, GridWindow { x0: 0, y0: 30, x1: 100, y1: 200 });
let image = embedded(&text);
assert_eq!((image.info.width, image.info.height), (100, 170));
assert_eq!(image.info.pixel_dims.map(|d| d.xppu), Some(10_000), "254 dpi");
let provider = Provider::default();
let text = pictured(&sheet(vec![picture(1, p(500.0, 500.0), p(510.0, 510.0), 1.0)]), 254.0, &provider);
assert!(!text.contains("<image") && provider.asked.borrow().is_empty());
}
/// A large picture is asked for a band of rows at a time: the bands follow each other and cover
/// its window, and the PNG holds every row.
#[test]
fn a_large_picture_is_asked_for_in_bands() {
// 4200 × 1100 px at 254 dpi, over 16 MiB of RGBA.
let provider = Provider::default();
let text = pictured(&sheet(vec![picture(1, p(0.0, 0.0), p(420.0, 110.0), 1.0)]), 254.0, &provider);
let asked = provider.asked.borrow();
assert!(asked.len() > 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);
}