diff --git a/.gitattributes b/.gitattributes
index f7e2def..2507352 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,2 +1,5 @@
* text=auto eol=lf
*.sh text eol=lf
+# Hand-written DXF fixtures: keep their bytes (CRLF, and a code page
+# that is not UTF-8) exactly as the generator wrote them
+crates/uncad/tests/fixtures/*.dxf -text
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6334a77..becf18e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,48 @@ Notable changes to this project are recorded here. The format follows
### Added
+- `uncad --version` / `-V` (both commands) and `--include-hidden` (draw what the layer
+ rules and the invisible flag leave out). The plain command now refuses an option it
+ does not know, a second input and an option without its value, naming what was wrong,
+ where it used to ignore them.
+
+- `uncad export -o
`: the CLI writes the LLM/VLM package through the new
+ `uncad-export` crate (`--profile`, `--max-levels`, `--max-tiles`, `--no-sheets`,
+ `--svg`); an option the subcommand does not know is an error.
+
+- `libredwg-sys` reads a drawing from memory (`uncad_dwg_read_bytes`,
+ `uncad_dxf_read_bytes`) -- LibreDWG's own file readers `fopen()` a byte string the
+ MSVC runtime reads in the ANSI code page, so a non-ASCII path fails on Windows -- and
+ exposes the file-header facts decoding a drawing's text and header needs:
+ `uncad_dwg_version`, `uncad_dwg_from_version`, `uncad_dwg_from_dxf`,
+ `uncad_dwg_numheader_vars` (how long a pre-R13 header is) and `uncad_dwg_template_read`
+ (whether the section holding `$MEASUREMENT` was read), with a binding for
+ `dwg_version_type`. Strings are not converted in C: `uncad`'s
+ `TextDecoder` does that, and reports what it cannot.
+- `uncad::parse_bytes(bytes, Format)` parses a drawing already in memory, and
+ `uncad::Format` (`Dwg`, `Dxf`, with `Format::from_path`) says which it is. `parse()`
+ now reads the file itself and decodes it from memory, so a path LibreDWG could not
+ open -- any non-ASCII one on Windows, such as a Korean directory name, which failed
+ with critical error 4096 -- parses; a file that cannot be read is the new
+ `ParseError::Io`. `tests/read_paths.rs`.
+- `uncad::parse_with_header` / `uncad::parse_bytes_with_header` return the drawing's
+ `uncad::Header` beside the model: the format, `$ACADVER` and LibreDWG's release name,
+ the codepage strings were decoded with, `$INSUNITS` (`Header::units()` gives the unit
+ and its millimetre factor), `$MEASUREMENT`, `$LUNITS`/`$LUPREC`/`$AUNITS`/`$AUPREC`,
+ the model and paper extents and limits, the `$DIM*` variables a dimension falls back
+ on, `$LTSCALE`, `$TEXTSIZE` and `$CLAYER`. A variable the file does not state is
+ `None` -- for a DWG by its version's header layout, for an ASCII DXF by what its HEADER
+ section names -- never the zero or default LibreDWG's struct holds for it. The header
+ is this crate's type, not the model's, which carries no header variables by design;
+ `parse()` and `parse_bytes()` are unchanged and return the database alone.
+ `tests/header.rs`.
+- Every crate carries the GPLv3 text as its own `LICENSE`; `cargo package` never
+ reaches the repository root's, so of the 0.2.0 tarballs only `libredwg-sys` had the
+ text (as LibreDWG's own `COPYING`) and `uncad-cli` had no licence file at all.
+ `libredwg-sys` also carries `NOTICE.md`, the modification notice for its vendored
+ LibreDWG. `uncad-cli`'s `tests/release_invariants.rs` keeps both true.
+- `libredwg-sys`'s build names libclang, and the command that installs it, when bindgen
+ cannot find it, instead of bindgen's own message.
- A SPLINE carries what defines its curve: `degree`, `knots`, `weights` (empty when
the file gives none -- every weight is 1), and the `closed` / `periodic` bits as
`Option`. A spline stored by its fit points has no periodic bit, and no
@@ -31,6 +73,57 @@ Notable changes to this project are recorded here. The format follows
- `POLYLINE_VERTICES` in `read_diagnostics`: a pre-R13 POLYLINE whose vertex records end
before its SEQEND (the object stream stops at a JUMP entity) is read with the vertices
found, and named. It used to arrive with no vertices and no signal.
+- A layer's state is read: `off`, `frozen`, `locked`, `plot` (DXF 290), `lineweight`
+ (DXF 370, hundredths of a millimetre or -3 for the default) and the `linetype` it names.
+ A DXF's plot flag and lineweight are `None` where its importer cannot tell a stated 0
+ from an absent group, and a drawing older than R2000 states neither. See
+ `docs/CAVEATS.md`, "Layer state: what a DXF cannot say".
+- POLYLINE_MESH (a polygon mesh) is read, as `Entity::PolylineMesh`: the wireframe of its
+ M by N grid, closed in either direction where the file says so. It used to arrive as
+ `Entity::Unknown`. A polyface mesh whose vertices the DXF importer types `VERTEX_MESH`
+ (they name the block record as their owner, the shape ezdxf writes) finds its vertex
+ positions: `example_2000.dxf`'s and `example_r13.dxf`'s polyface had no edges where
+ their DWG twins have six.
+- TEXT, ATTRIB and ATTDEF carry their `elevation` and `extrusion` too, and an INSERT its
+ `extrusion`, with the coordinates as the file states them: a mirrored block reference
+ used to be indistinguishable from an upright one. A normal the record does not store --
+ an LWPOLYLINE's unless its flag says so, a pre-R13 entity's unless its options do -- is
+ the default (0, 0, 1), not the zero vector the library leaves in the field. See
+ `docs/CAVEATS.md`, "Object coordinate systems".
+- A polyline vertex carries the widths of the segment that leaves it, `start_width` and
+ `end_width` (DXF 40/41), and an LWPOLYLINE its `const_width` (DXF 43), the width of
+ every segment when no vertex has one of its own. A file that states the constant width
+ again on every vertex reads as one that states it once. A DXF POLYLINE's default widths
+ (its groups 40/41) are the widths of the vertices that state none, so a DXF and its DWG
+ twin agree. An LWPOLYLINE width array that cannot be matched to the vertices is reported
+ as `POLYLINE_WIDTH` and read as no widths. See `docs/CAVEATS.md`, "Where a polyline's
+ widths come from".
+- TEXT, ATTRIB and ATTDEF carry how they are placed beyond their start point: the
+ horizontal and vertical justification (DXF 72, 73/74), the alignment point (DXF 11,
+ only for a justified text), the width factor (DXF 41), the oblique angle (DXF 51,
+ radians) and the text style they name (DXF 7). An MTEXT carries its reference width
+ (DXF 41), the extents its writer measured (DXF 42/43, `None` when not stated) and its
+ text style.
+- ATTRIB and ATTDEF carry their `flags` (DXF 70): invisible, constant, verify, preset. An
+ invisible attribute (a title block's hidden field, say) is no longer indistinguishable
+ from a shown one.
+- A DIMSTYLE carries the rest of what a dimension's displayed text depends on, each as an
+ `Option` like the others: `arrow_size` (DIMASZ), `linear_unit_format` (DIMLUNIT),
+ `zero_suppression` (DIMZIN), `rounding` (DIMRND), `angular_unit_format` (DIMAUNIT),
+ `angular_decimal_places` (DIMADEC) and `fraction_format` (DIMFRAC). What to use where a
+ style states nothing is the consumer's decision.
+- An MLINE carries its `scale` (DXF 40), the factor its style's offsets are drawn at: a
+ wall drawn 20 units thick in a style of unit offsets was drawn 1 unit thick.
+- The drawing's layouts are read into `Tables::layouts`: every LAYOUT object -- a tab, the
+ block it shows (`block_name`), its tab order and limits -- with the plot settings
+ embedded in it (paper name and size, margins, plot origin, paper unit, rotation and the
+ custom scale), read through LibreDWG's dynapi into the embedded `PLOTSETTINGS` struct.
+- A VIEWPORT carries what it shows of the model: its `view` (centre and height in the
+ view's own coordinates, target, direction, twist and lens length; `None` before R2000,
+ whose viewports keep it in extended data), whether it is `on`, its `viewport_id` (a
+ DXF's; the binary format stores none) and the layers frozen in it alone.
+- An ordinate DIMENSION carries its `ordinate_axis` (DXF 70, bit 64): whether it measures
+ its feature's x or y distance from the datum.
- `AttribEntity::tag` and `AttdefEntity::tag` (DXF 2): the name an attribute value
answers to. A title block's values were readable but not which field each one filled.
- `CadDatabase::read_diagnostics`: the non-fatal problems LibreDWG reported while
@@ -49,20 +142,49 @@ Notable changes to this project are recorded here. The format follows
here was not read, not empty; the edges used to be skipped in silence.
- TRACE is read and drawn: `Entity::Trace`, which reuses `SolidEntity` the way `XLine`
reuses `RayEntity`. It used to arrive as `Entity::Unknown`.
-- All three crates declare `rust-version = "1.88"`. Until now the minimum was whatever
+- Every crate declares `rust-version = "1.88"`. Until now the minimum was whatever
happened to build. The value is measured (1.87 fails, 1.88 passes) and CI has an `msrv`
job that checks the workspace with exactly the declared toolchain.
+- `uncad-export` 0.1.0, a new crate in this workspace: the LLM/VLM package, which does not
+ belong in `uncad` (a parser; it draws nothing) or in the renderer (which knows nothing of
+ who reads its pictures). It bundles the font its images are drawn and measured with,
+ `Uncad Sans`, a Noto Sans KR subset under the SIL Open Font License 1.1, handed to the
+ renderer as `fonts::bundled()` with the face's capital height (`fonts::UNCAD_SANS_CAP_HEIGHT`,
+ 0.733). Its licence expression is `GPL-3.0-or-later AND OFL-1.1`; `deny.toml` grants the
+ crate both by name, and `uncad-cli`'s `tests/release_invariants.rs` checks its `LICENSE`,
+ the font's licence text beside the font and the expression.
+ `export_package(db, header, dir, options)` (and `export_file`) writes the package: an
+ overview image fitted to a model's patch budget (`Profile`: Claude, Claude hi-res,
+ OpenAI), drawn once through the renderer's `Scene`; JSON records -- texts with the box
+ their glyph outlines fill, dimensions with their value, where it came from and their
+ label, geometry with lengths and areas, closed regions with their labels, block
+ instances -- whose ids are the model's reference IDs, with the file's handle beside
+ them; an NFKC string index; record shards with an index a consumer can search;
+ `drawing.json`, `report.json` (what was left out, hidden and bounded, and why) and a
+ manifest saying what the package can answer. The drawing splits into frames (a detail
+ beside the plan is its own), each with a pyramid of overlapping tiles as deep as its
+ text needs, culled to the tiles something visible reaches -- by the measured text boxes,
+ not the estimate -- and hashed in `tiles.json`; every tile has a sidecar under 32 KB
+ saying what is on it. Each paper layout is a sheet image, drawn by the renderer's layout
+ rendering with the model composited through its viewports, and `sheets.json` states each
+ composited viewport's model-to-paper map in its own fields; the sheets' own texts (the
+ title, the title block) are text records on their sheet. The same input gives the same
+ bytes, `report.json` included.
### Changed
-- A polyline's vertices are `PolylineVertex { point, bulge }` -- LWPOLYLINE, 2D
- POLYLINE and a HATCH's polyline boundaries. The bulge (DXF 42) is an arc segment's;
- it was read and dropped, so an arc segment arrived as its chord. A bulge array that
- cannot be matched to the vertices is reported as `POLYLINE_BULGE` and read as
- straight.
+- A dimension whose group 42 is `-1`, the value writers leave for a dimension they did not
+ measure, reports `measurement: None` rather than `-1.0`, the way a `0` already did.
+- A polyline's vertices are `PolylineVertex { point, bulge, start_width, end_width }` --
+ LWPOLYLINE, 2D POLYLINE and a HATCH's polyline boundaries. The bulge (DXF 42) is an arc
+ segment's; it was read and dropped, so an arc segment arrived as its chord. A bulge array
+ that cannot be matched to the vertices is reported as `POLYLINE_BULGE` and read as
+ straight. (The widths are under "Added".)
- `libredwg-sys` no longer binds `dwg_object_polyline_2d_get_points`,
`dwg_object_polyline_2d_get_numpoints`, `dwg_object_polyline_3d_get_points` or
- `dwg_object_polyline_3d_get_numpoints` (see Fixed), and binds `dwg_next_object`.
+ `dwg_object_polyline_3d_get_numpoints` (see Fixed), and binds `dwg_next_object` and
+ `dwg_rgb_palette_index` (the RGB the DXF importer makes up for a colour index; see
+ Fixed, true colour).
- A LEADER's `annotation_id` is a three-state `Ref`: `Resolved` names an
entity of the drawing, `Unresolved` keeps the handle the file wrote (hex) when no
@@ -82,13 +204,44 @@ Notable changes to this project are recorded here. The format follows
### Fixed
+- **The vendored LibreDWG carries five local patches**, each marked `uncad local patch`
+ in the source and listed in `crates/libredwg-sys/NOTICE.md` and `docs/CAVEATS.md`,
+ "Local patches to the vendored LibreDWG". A DXF holding a polygon mesh is read instead
+ of refused as a whole (critical error 2048: the importer did not know the mesh
+ vertices' `AcDbPolygonMeshVertex` marker). A corrupt header date no longer ends the
+ process from inside the C library (0xC0000409 on Windows, from `strftime`). The
+ importer compares an R2007+ DXF's table-record names decoded, so its layer and block
+ lookups no longer stop at the first character: without that patch `example_2018.dxf`
+ reads with 65 of its 72 entities on no layer (`tests/r2007_dxf_handles.rs`). An R2004+
+ entity carrying both a true colour and a transparency no longer has the two swapped in
+ the library's fields (`2004/HatchG.dwg`'s HATCH 29F: `0x1ae464`, not `0x0000e5`). When
+ the patches landed, the JSON output of the 208 corpus drawings was byte-identical with
+ and without them; the colour-order one shows since true colours are read as the file
+ states them (below), and the name-lookup one since R2007+ DXF is read. `build.rs`
+ refuses to build when a patch's marker has gone missing, which a re-vendor through
+ `scripts/sync-libredwg-vendor.sh` would otherwise do in silence.
- A 2D or 3D POLYLINE from an R13 to R2000 drawing no longer loses its last vertex. The
library's point accessors stop one record early in that range; the vertex records are
- now walked directly.
+ now walked directly. A closed square came back a triangle, a two-vertex arc a single
+ point; all 22 POLYLINEs of the corpus DXFs and the fixtures whose vertices can be
+ counted in the file now have that count. A polyline's VERTEX records are no longer
+ reported as entities of their own either -- 62 `Unknown` VERTEX entities in seven
+ pre-R13 DXFs. See `docs/CAVEATS.md`, "How a 2D or 3D POLYLINE's vertices are found".
+- **An entity's true colour is the one the file states.** It was read only when the
+ colour's method said TRUECOLOR: an R2004+ DWG never sets the method (the RGB comes under
+ the colour's `0x80` flag), so no DWG entity reported its true colour, while the DXF
+ importer sets it for a plain group 62 with an RGB taken from its own palette, so a DXF
+ entity with only an ACI index reported an RGB the file never wrote. The flag is read
+ first, and a DXF RGB that is the one the library synthesises for the entity's index is
+ not a true colour. See `docs/CAVEATS.md`, "An entity's true colour is what the file
+ states".
- `MTextEntity::rotation` is the direction of the text's X axis instead of a constant `0`,
which reported rotated multi-line text as horizontal.
- A two-line angular dimension's `definition_point` (DXF 10) is read instead of reported as
- not stated: the library keeps it under the field name `xline2end_pt`.
+ not stated: the library keeps it under the field name `xline2end_pt`. Read from a DXF,
+ the same dimension had its groups 10 and 16 (`definition_point` and `points.arc`)
+ exchanged: LibreDWG's DXF importer fills those two fields by group code, its DWG decoder
+ in stream order.
- An MTEXT from a drawing older than R2000 reports a line spacing factor of `1` (the format
has no such field there) instead of `0`, which is outside the factor's valid range.
- A LEADER's `has_arrowhead` read from an R2010-or-later DWG is `None`. The vendored
@@ -96,9 +249,9 @@ Notable changes to this project are recorded here. The format follows
which those files still carry), so the value it returned for the flag was a bit of the
offset's encoding -- "no arrowhead" whenever the offset's z was zero -- not the file's
flag. Earlier versions are unaffected and still read the flag.
-- Two user-facing messages carried a run of spaces in mid-sentence: the refusal of an
- R2007+ DXF (`ParseError::UnsupportedDxfVersion`'s `Display`) and the missing-tag
- diagnostic. Both now read as one sentence, pinned by tests.
+- Two user-facing messages carried a run of spaces in mid-sentence: the R2007+ DXF error
+ (`ParseError::UnsupportedDxfVersion`'s `Display`) and the missing-tag diagnostic. Both
+ now read as one sentence, pinned by tests.
- **Text before R2007 is decoded through the drawing's codepage** (`header.codepage`, DXF
`$DWGCODEPAGE`). LibreDWG returns such strings as the 8-bit bytes
the file holds; they were read as UTF-8, so every non-ASCII character of a CP949 or CP1252
@@ -107,6 +260,14 @@ Notable changes to this project are recorded here. The format follows
tables, and a byte the declared codepage has no character for is now U+FFFD *and* a
`TEXT_ENCODING: ...` warning in `read_diagnostics` naming the entity and field. See
`docs/CAVEATS.md`, "Text before R2007 is decoded here".
+- **The DOS-era double-byte codepages decode.** LibreDWG's tables pair every byte of a
+ Big5 or GB2312 string, ASCII included, so a drawing declaring either lost its
+ `*Model_Space` (read as `*M`, `od`, ...) and every entity, and `中国 AB` read as four
+ U+FFFD; CP932 (DOS Shift-JIS) was read one byte at a time. Only bytes >= 0x80 open a
+ pair now, a GB2312 pair is looked up in the 7-bit form the library's table is indexed
+ by, and CP932 is double-byte. ASCII is never looked up in any codepage's table, so a
+ 0x5C stays the backslash of `\P` and `\U+XXXX` where CP932's and JOHAB's tables say yen
+ and won. `tests/codepage.rs`.
- **Closed LWPOLYLINEs are closed.** The `closed` field read bit 1 of the entity's `flag`,
which in the library's LWPOLYLINE layout means "has extrusion"; closed is bit 512. Not one
of the corpus's 1,137 LWPOLYLINEs had ever been reported closed, so every closed outline
@@ -152,8 +313,8 @@ Notable changes to this project are recorded here. The format follows
directories `cc` located. Bindings are also generated *before* the C compile, so a
libclang problem fails in seconds rather than after the whole LibreDWG compile.
- A DXF saved as R2007 or later used to read as an empty drawing without any error; it
- is now refused (see "Changed"). `docs/CAVEATS.md` explains the cause (string width in
- LibreDWG's DXF importer) and why reading it partially was rejected.
+ is now read (see "Changed"). `docs/CAVEATS.md` explains the cause (string width, both in
+ LibreDWG's DXF importer and in how this crate read what it stored).
- `docs/CAVEATS.md` claimed every entity type with geometry was handled. It is not --
several types that have a shape still arrive as `Unknown`. The section now states the
supported list as the contract.
@@ -211,14 +372,20 @@ Notable changes to this project are recorded here. The format follows
shape** of every entity (`common.layer`) and is the reason the next release is a 0.x
minor. `Ref::name()` gives the resolved name or `""` for consumers that only need a
lookup key.
-- A DXF saved as R2007 or later (`$ACADVER` `AC1021` and up) is now refused with
- `ParseError::UnsupportedDxfVersion` instead of being returned as a drawing with no
- entities and no error. The decision is made from the file's HEADER section before
- LibreDWG reads it; R2000/R2004 DXF, files without `$ACADVER`, binary DXF and every DWG
- are unaffected. Callers that treated the empty result as success will now see an error
- -- that is the point. `ParseError` is `#[non_exhaustive]`, so the new variant is not a
- breaking change to matches. Two tests pin it: a walk of the corpus that requires exactly
- the R2007+ files to be refused, and one drawing under two `$ACADVER` values.
+- **A DXF saved as R2007 or later (`$ACADVER` `AC1021` and up) is read**, instead of being
+ returned as a drawing with no entities and no error. LibreDWG's importer holds such a
+ file's strings in two widths -- UTF-16 for everything it sets through its field setter,
+ the file's own UTF-8 for MTEXT text and the HEADER variables -- and hands both out as if
+ they were 8-bit; `TextDecoder` now reads each in its width, and the vendored `dwg.c`
+ patch does the same for the importer's own layer and block lookups. Measured on the
+ corpus: 27 of the 32 R2007+ DXFs read (the other 5 fail inside LibreDWG with critical
+ error 2048), and 22 of the 24 with a DWG twin state the same layers and INSERT blocks
+ as it; `tests/dxf_pipeline.rs`, `tests/r2007_dxf_handles.rs` and `tests/codepage.rs`
+ pin it. `ParseError::UnsupportedDxfVersion` stays, for the case this used to be: an
+ R2007+ DXF whose entities LibreDWG placed in model or paper space and none of which
+ reached the model is an error, not an empty drawing (no corpus file is).
+- `ParseError::InvalidPath` is gone: `parse()` no longer hands LibreDWG a C path, and a
+ file it cannot read is `ParseError::Io`. Breaking for code that names the variant.
- No `std` hash collections anywhere in the workspace: `clippy.toml` disallows `HashMap`
and `HashSet`, and the `iter_over_hash_type` lint is on. Both earlier ordering bugs went
through `into_iter()`/`into_values()`, which no lint on `for` loops would have seen.
diff --git a/Cargo.toml b/Cargo.toml
index 2674470..9597abb 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,10 +1,10 @@
[workspace]
resolver = "2"
-members = ["crates/libredwg-sys", "crates/uncad", "crates/uncad-cli"]
+members = ["crates/libredwg-sys", "crates/uncad", "crates/uncad-export", "crates/uncad-cli"]
[workspace.package]
edition = "2021"
-# Minimum supported Rust version, for all three crates. Measured, not guessed:
+# Minimum supported Rust version, for all four crates. Measured, not guessed:
# 1.87 fails (`slice::as_chunks`, stable since 1.88, in crates/uncad) and 1.88
# passes. CI's `msrv` job reads this value and checks the workspace with
# exactly that toolchain -- raise the two together, never this line alone.
diff --git a/README.md b/README.md
index e375ed6..09f6074 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,9 @@
[](LICENSE)
An open-source Rust library that parses CAD files (DWG/DXF) into a model and
-exports that model as JSON, SVG or PNG. Read-only: it does not write DWG or DXF,
+exports that model as JSON, SVG or PNG -- and, through the `uncad-export` crate,
+as a directory an LLM or VLM can read (overview and tile images, sheet images,
+and JSON records that point into them). Read-only: it does not write DWG or DXF,
and does not convert between them.
## Quick start
@@ -19,12 +21,28 @@ cargo test --workspace
let db = uncad::parse("drawing.dwg")?; // same model for DWG and DXF (entities + tables)
println!("{} entities", db.entities.len());
+// The header variables that give the numbers a meaning come beside the model:
+let (db, header) = uncad::parse_with_header("drawing.dxf")?;
+println!("units: {:?}", header.units()); // $INSUNITS; None when the file does not say
+
+// Bytes already in memory (from the network, an archive, ...):
+let bytes = std::fs::read("drawing.dwg")?;
+let db = uncad::parse_bytes(&bytes, uncad::Format::Dwg)?;
+
let json = db.to_json(uncad::ToJsonOptions { pretty: true })?; // the model, serialized as-is
std::fs::write("drawing.json", json)?;
// Rendering is the iron-render-cad crate's (uncad-cli uses it):
let result = iron_render_cad::to_svg(&db, iron_render_cad::ToSvgOptions::default());
std::fs::write("drawing.svg", result.svg)?;
+
+// The LLM/VLM package (the uncad-export crate; uncad-cli's `uncad export`):
+let report = uncad_export::export_file(
+ std::path::Path::new("drawing.dwg"),
+ std::path::Path::new("drawing.pkg"),
+ &uncad_export::ExportOptions::default(),
+)?;
+println!("{} files, {} sheets", report.files.len(), report.sheets.len());
```
## CLI
@@ -38,18 +56,27 @@ cargo run -p uncad-cli -- drawing.dwg -o drawing.png --scale 2 # rasterize at
cargo run -p uncad-cli -- drawing.dwg -o drawing.svg --no-trim # keep outlying coordinates
cargo run -p uncad-cli -- drawing.dwg -o sheet.svg --space paper # sheet borders / title blocks
cargo run -p uncad-cli -- drawing.dwg -o all.svg --space all # every space in one document
+cargo run -p uncad-cli -- drawing.dwg -o all.svg --include-hidden # also what layers hide
+cargo run -p uncad-cli -- export drawing.dwg -o drawing.pkg # the LLM/VLM package
+cargo run -p uncad-cli -- export drawing.dwg -o drawing.pkg --profile openai-patch --max-levels 2
+cargo run -p uncad-cli -- --version
```
+An option the CLI does not know is an error that names it, on both commands.
+`uncad export` writes `manifest.json` last; start reading a package there (its
+`README.txt` says the same in prose).
+
## Scope
1. **DWG** — read through [LibreDWG](https://www.gnu.org/software/libredwg/)
(GPLv3+), bound directly via Rust FFI (`bindgen`). All versions.
-2. **DXF** — read through the same LibreDWG engine, chosen by file extension.
- **DXF saved as R2007 or later is refused** with `ParseError::UnsupportedDxfVersion`
- rather than read incompletely — see `docs/CAVEATS.md`, "DXF reading". R2000/R2004
- DXF and every DWG version are unaffected.
- LibreDWG's own DXF importer is documented as working "for most objects", so
- it is less complete than its DWG reading ([`docs/CAVEATS.md`](./docs/CAVEATS.md)).
+2. **DXF** — read through the same LibreDWG engine, chosen by file extension
+ (`parse`) or by the caller (`parse_bytes`). Every version, R2007 and later
+ included: such a file holds its strings in two widths in LibreDWG's memory,
+ and each is read in its own — see `docs/CAVEATS.md`, "DXF saved as R2007 or
+ later is read". LibreDWG's own DXF importer is documented as working "for most
+ objects", so it is less complete than its DWG reading, and slower than linear
+ in the entity count ([`docs/CAVEATS.md`](./docs/CAVEATS.md)).
3. **Output** — the parsed model as JSON (`CadDatabase::to_json`, from
`uncad-model`). SVG and PNG come from the
[`iron-render-cad`](https://github.com/iyulab/iron-render-cad) crate (MIT), which
@@ -81,8 +108,11 @@ well as Windows.
## License
**GPLv3-or-later**. LibreDWG (GPLv3+) is the only third-party component linked
-in, and its license carries over. Copyright and license details for third-party
-components are in [`docs/THIRD_PARTY_NOTICES.md`](./docs/THIRD_PARTY_NOTICES.md).
+in, and its license carries over. The vendored copy carries five local patches;
+`crates/libredwg-sys/NOTICE.md` is their modification notice, inside the crate
+so that it reaches the published tarball. Copyright and license details for
+third-party components are in
+[`docs/THIRD_PARTY_NOTICES.md`](./docs/THIRD_PARTY_NOTICES.md).
## Repository layout
@@ -91,12 +121,17 @@ lib/libredwg/ LibreDWG upstream, as a git submodule. Not used by the
it is the source vendor/ is regenerated from, and where the
real-file test fixtures (test/test-data/) come from
crates/
- libredwg-sys/ raw FFI (cc + bindgen). vendor/libredwg/ holds the unmodified
- subset of C sources actually compiled (for publishing to
- crates.io); shim/ holds the C accessors for opaque types, and
+ libredwg-sys/ raw FFI (cc + bindgen). vendor/libredwg/ holds the subset of C
+ sources actually compiled (for publishing to crates.io), with
+ five local patches marked "uncad local patch" and listed in
+ NOTICE.md; shim/ holds the C accessors for opaque types, and
vendor-config/config.h stands in for autotools
- uncad/ the safe API: parse() -> uncad_model::CadDatabase
- uncad-cli/ the CLI binary (uncad)
+ uncad/ the safe API: parse() / parse_bytes() -> uncad_model::CadDatabase,
+ and the drawing's Header beside it from parse_with_header()
+ uncad-export/ the LLM/VLM package: export_file() / export_package() write a
+ directory of images and JSON records; bundles a Noto Sans KR
+ subset (OFL-1.1) so Korean text renders anywhere
+ uncad-cli/ the CLI binary (uncad), including `uncad export`
crates/*/tests/ integration tests against the public API. crates/*/examples/ are
manual-check tools, and #[cfg(test)] blocks inside src/*.rs are
unit tests -- docs/ARCHITECTURE.md's "Test layout" says which
@@ -115,3 +150,5 @@ Further reading:
test layout, the FFI/bindgen boundary, thread safety, the entity model
- [`docs/CAVEATS.md`](./docs/CAVEATS.md) — entity type coverage, known
limitations and bugs, cross-platform notes
+- [`docs/VLM_EXPORT_DESIGN.md`](./docs/VLM_EXPORT_DESIGN.md) — the LLM/VLM
+ package `uncad export` writes: its files, coordinates, tiling and crop rules
diff --git a/crates/libredwg-sys/Cargo.toml b/crates/libredwg-sys/Cargo.toml
index f4a17f2..7e36423 100644
--- a/crates/libredwg-sys/Cargo.toml
+++ b/crates/libredwg-sys/Cargo.toml
@@ -7,7 +7,7 @@ license.workspace = true
repository.workspace = true
homepage.workspace = true
readme.workspace = true
-description = "Raw FFI bindings to the LibreDWG C sources (vendored, unmodified upstream), built natively via cc + bindgen (no Emscripten/WASM)."
+description = "Raw FFI bindings to the LibreDWG C sources (a vendored subset of upstream carrying five local patches, see NOTICE.md), built natively via cc + bindgen (no Emscripten/WASM)."
keywords = ["dwg", "dxf", "ffi", "libredwg", "cad"]
categories = ["external-ffi-bindings"]
links = "libredwg"
diff --git a/crates/libredwg-sys/LICENSE b/crates/libredwg-sys/LICENSE
new file mode 100644
index 0000000..94a9ed0
--- /dev/null
+++ b/crates/libredwg-sys/LICENSE
@@ -0,0 +1,674 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ Copyright (C)
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+.
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+.
diff --git a/crates/libredwg-sys/NOTICE.md b/crates/libredwg-sys/NOTICE.md
new file mode 100644
index 0000000..1a4453e
--- /dev/null
+++ b/crates/libredwg-sys/NOTICE.md
@@ -0,0 +1,64 @@
+# Notices for `libredwg-sys`
+
+`libredwg-sys` is GPL-3.0-or-later. The licence text is `LICENSE` beside this
+file; it is the same text as LibreDWG's own `vendor/libredwg/COPYING`.
+
+## LibreDWG — vendored, and **modified**
+
+- **Upstream**: https://github.com/LibreDWG/libredwg
+- **Copyright**: Free Software Foundation, Inc.
+- **Licence**: GNU General Public License v3.0 or later; upstream's own copy
+ travels in this tarball as `vendor/libredwg/COPYING`.
+- **What is here**: not all of LibreDWG. `vendor/libredwg/` holds only the
+ files `build.rs` actually compiles or `#include`s — 24 `.c` files plus the
+ headers, `.spec`, `.inc` and code-page tables they reach, traced from the
+ real include graph. Autotools' generated `config.h` is stood in for by this
+ crate's own `vendor-config/config.h`, a separate file rather than a patch to
+ LibreDWG's sources.
+- **Modified**: **yes**, in five files. Each change carries an
+ `uncad local patch` comment in the source saying what changed and why
+ (GPLv3 §5(a)); `grep -rn "uncad local patch" vendor/libredwg/` finds every
+ marker, and `build.rs` refuses to build when the markers per file differ
+ from the list it carries.
+
+### The five changes
+
+The markers in `dwg.c` and `common.c` were written when those two were the
+only changes, and cite this list as "The two changes"; it is this one.
+
+1. **`vendor/libredwg/src/dwg.c`**, 2026-09-23 — `dwg_find_tablehandle()`,
+ `dwg_find_dicthandle_objname()` and `dwg_handle_name()` read a table
+ record's name through a new `uncad_record_name_utf8()` helper instead of
+ `dwg_dynapi_entity_utf8text()`, so an R2007+ DXF's UTF-16 record names are
+ decoded before they are compared. Upstream compares the raw UTF-16 bytes
+ and so never matches such a name.
+2. **`vendor/libredwg/src/common.c`**, 2026-09-23 — `cvt_TIMEBLL()` zeroes its
+ `struct tm` and clamps every field into the range `strftime()` accepts, so
+ a corrupt date in a hostile file cannot abort the process from inside a
+ library call.
+3. **`vendor/libredwg/src/in_dxf.c`**, 2026-09-23 — the VERTEX_2D upgrade
+ chain knows `AcDbPolygonMeshVertex`, the subclass marker AutoCAD writes on
+ a polygon mesh's vertices. Without it the object stayed a VERTEX_2D whose
+ subclass list does not hold that name, which is a *critical* error: one
+ polygon mesh made the whole DXF unreadable.
+4. **`vendor/libredwg/src/dynapi.c`**, 2026-09-23 — the same name added to
+ VERTEX_MESH's row in `dwg_name_subclasses[]`, which is the table the check
+ above consults.
+5. **`vendor/libredwg/src/common_entity_data.spec`**, 2026-09-23 — the R2004+
+ entity colour reads its RGB before its transparency, the order LibreDWG's
+ own `bit_read_ENC()` uses. With the two the other way round, an entity
+ carrying both had its colour and its transparency swapped.
+
+None of the changes alters LibreDWG's file formats or its API. Each changes
+what the library reads only where upstream misreads a drawing, refuses it, or
+ends the process on it.
+
+The full reasoning, with the reproduction for each, is in the project's
+`docs/CAVEATS.md` under "Local patches to the vendored LibreDWG" — that file
+is *not* in this tarball; it is at https://github.com/iyulab/uncad.
+
+## Everything else
+
+`libredwg-sys` bundles no other third-party source. The project-wide notice
+file, covering the Rust dependency licences, is `docs/THIRD_PARTY_NOTICES.md`
+in the repository above.
diff --git a/crates/libredwg-sys/build.rs b/crates/libredwg-sys/build.rs
index 6bc59e5..12a48eb 100644
--- a/crates/libredwg-sys/build.rs
+++ b/crates/libredwg-sys/build.rs
@@ -1,4 +1,4 @@
-use std::path::PathBuf;
+use std::path::{Path, PathBuf};
// Core LibreDWG sources actually needed for DWG/DXF *reading*, mirroring the
// former WASM build's `emmake make -C src` scope (which itself excludes
@@ -42,6 +42,24 @@ const LIBREDWG_SOURCES: &[&str] = &[
"reedsolomon.c",
];
+/// The comment every local change to the vendored LibreDWG carries.
+const LOCAL_PATCH_MARKER: &[u8] = b"uncad local patch";
+
+// The local patches the vendored copy carries, as (path under
+// vendor/libredwg, times LOCAL_PATCH_MARKER occurs in that file). NOTICE.md
+// lists the same five changes and docs/CAVEATS.md, "Local patches to the
+// vendored LibreDWG", says why each exists. scripts/sync-libredwg-vendor.sh
+// deletes and recopies the whole directory, so a re-vendor silently drops
+// every one of them; main() compares the tree against this table so that it
+// cannot.
+const LOCAL_PATCHES: &[(&str, usize)] = &[
+ ("src/common.c", 3),
+ ("src/common_entity_data.spec", 2),
+ ("src/dwg.c", 5),
+ ("src/dynapi.c", 1),
+ ("src/in_dxf.c", 2),
+];
+
fn main() {
// vendor-config/config.h hardcodes SIZEOF_SIZE_T to 8 with no platform
// branch (unlike its neighboring SIZEOF_WCHAR_T, which does branch on
@@ -61,6 +79,12 @@ fn main() {
and unsafe to silently use on a {pointer_width}-bit target. See docs/CAVEATS.md."
);
+ // The other prerequisite, and the one a crates.io consumer is actually
+ // likely to be missing. Checked before anything else so that its absence
+ // is reported by name, with the command that installs it, rather than as
+ // bindgen's own message further down.
+ check_libclang();
+
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
// Build from the vendored copy inside the crate (vendor/libredwg), not
@@ -99,6 +123,28 @@ fn main() {
);
}
+ // Patch detector: the file count above does not change when a re-vendor
+ // replaces a patched file with upstream's, so the markers are counted too.
+ // A file that lost (or gained) a marker fails the build by name.
+ let vendor_root = libredwg_src
+ .parent()
+ .expect("vendor/libredwg/src always has a parent directory");
+ let found_patches = local_patch_markers(vendor_root);
+ let expected_patches: Vec<(String, usize)> = LOCAL_PATCHES
+ .iter()
+ .map(|&(path, count)| (path.to_string(), count))
+ .collect();
+ if found_patches != expected_patches {
+ panic!(
+ "vendor/libredwg carries these `uncad local patch` markers (file, count): \
+ {found_patches:?}, but build.rs expects {expected_patches:?}. \
+ scripts/sync-libredwg-vendor.sh deletes the local patches on every run: re-apply \
+ them (docs/CAVEATS.md, \"Local patches to the vendored LibreDWG\"), or, if one \
+ was dropped or added on purpose, update LOCAL_PATCHES in build.rs, NOTICE.md and \
+ docs/CAVEATS.md together."
+ );
+ }
+
// Dev-only cross-check: when the lib/libredwg submodule is also checked
// out (local dev / CI clone with submodules, not a published-crate
// build), warn if its .c file set has drifted from the vendored copy --
@@ -197,6 +243,18 @@ fn main() {
.allowlist_function("uncad_dwg_is_r2013_or_later")
.allowlist_function("uncad_dwg_codepage")
.allowlist_function("uncad_dwg_is_wide_string")
+ // Reading from memory (Unicode paths on Windows, in-memory inputs)
+ // and the file-header accessors -- see shim/uncad_shim.h for why
+ // each exists.
+ .allowlist_function("uncad_dwg_read_bytes")
+ .allowlist_function("uncad_dxf_read_bytes")
+ .allowlist_function("uncad_dwg_version")
+ .allowlist_function("uncad_dwg_from_version")
+ .allowlist_function("uncad_dwg_from_dxf")
+ .allowlist_function("uncad_dwg_numheader_vars")
+ .allowlist_function("uncad_dwg_template_read")
+ // Version enum -> "r2004"-style name, for the parsed header.
+ .allowlist_function("dwg_version_type")
// The codepage tables (src/codepages.h): what decodes a pre-R2007
// string's bytes, since the library's text accessors return them
// undecoded. Predicates plus the byte -> code point lookups.
@@ -206,6 +264,10 @@ fn main() {
.allowlist_function("dwg_codepage_uc")
.allowlist_function("dwg_codepage_uwc")
.allowlist_function("dwg_codepage_dxfstr")
+ // The RGB the library's DXF importer synthesises for a plain ACI
+ // index (dxf_set_CMC_index): how an entity's stated true colour is
+ // told from one the importer made up.
+ .allowlist_function("dwg_rgb_palette_index")
// The handle of the object a type-specific struct pointer belongs
// to, for naming a string in a diagnostic.
.allowlist_function("dwg_obj_generic_handlevalue")
@@ -349,6 +411,101 @@ fn main() {
);
}
+/// Every file under `root` that contains [`LOCAL_PATCH_MARKER`], as its
+/// `/`-separated path relative to `root` and the number of times the marker
+/// occurs in it, sorted by path.
+fn local_patch_markers(root: &Path) -> Vec<(String, usize)> {
+ fn walk(dir: &Path, root: &Path, out: &mut Vec<(String, usize)>) {
+ let entries =
+ std::fs::read_dir(dir).unwrap_or_else(|e| panic!("cannot read {}: {e}", dir.display()));
+ for entry in entries {
+ let path = entry
+ .unwrap_or_else(|e| panic!("cannot read an entry of {}: {e}", dir.display()))
+ .path();
+ if path.is_dir() {
+ walk(&path, root, out);
+ continue;
+ }
+ let bytes = std::fs::read(&path)
+ .unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()));
+ let count = bytes
+ .windows(LOCAL_PATCH_MARKER.len())
+ .filter(|window| *window == LOCAL_PATCH_MARKER)
+ .count();
+ if count > 0 {
+ let relative = path
+ .strip_prefix(root)
+ .expect("the walk stays under its root")
+ .to_string_lossy()
+ .replace('\\', "/");
+ out.push((relative, count));
+ }
+ }
+ }
+ let mut out = Vec::new();
+ walk(root, root, &mut out);
+ out.sort();
+ out
+}
+
+/// Fails early, and by name, when libclang is missing.
+///
+/// bindgen needs libclang at build time. Without it the build dies inside
+/// `Builder::generate()` below, under a screenful of `cc` environment dump,
+/// with `Unable to find libclang: "couldn't find any valid shared libraries
+/// matching: ['clang.dll', 'libclang.dll'] ..."` -- which names neither this
+/// crate, nor LLVM, nor the command that fixes it. README.md's "Platform"
+/// section documents the prerequisite, and someone running
+/// `cargo install uncad-cli` never reads README.md. (bindgen runs before the
+/// C compile, so that failure already comes within seconds; what this adds is
+/// a message that says what to install.)
+///
+/// `bindgen::clang_version()` makes the same `ensure_libclang_is_loaded()`
+/// call `generate()` does, so it fails exactly where `generate()` would --
+/// by panicking rather than returning an error, which is why this probes it
+/// through `catch_unwind` instead of matching a `Result`. Cargo always
+/// builds build scripts with `panic = "unwind"` (a profile's `panic` setting
+/// does not reach them), so the unwind is caught rather than aborting.
+fn check_libclang() {
+ let previous_hook = std::panic::take_hook();
+ // Swallow bindgen's own message; the panic below replaces it.
+ std::panic::set_hook(Box::new(|_| {}));
+ let found = std::panic::catch_unwind(bindgen::clang_version).is_ok();
+ std::panic::set_hook(previous_hook);
+ if found {
+ return;
+ }
+
+ // A wrong LIBCLANG_PATH is its own failure mode, and bindgen's message
+ // reports it as an empty candidate list.
+ let stale_path = match std::env::var("LIBCLANG_PATH") {
+ Ok(path) => {
+ format!("LIBCLANG_PATH is set to '{path}', and no clang library was found there.\n")
+ }
+ Err(_) => String::new(),
+ };
+ panic!(
+ "
+libredwg-sys generates its FFI bindings with bindgen, which needs libclang,
+and no libclang could be found.
+{stale_path}
+Install LLVM/Clang, then build again:
+ Windows winget install LLVM.LLVM (or: choco install llvm)
+ if it is still not found afterwards, set
+ LIBCLANG_PATH to the directory holding libclang.dll
+ (a default install puts it in LLVM/bin under
+ Program Files)
+ Debian/Ubuntu sudo apt install libclang-dev
+ Fedora/RHEL sudo dnf install clang-devel
+ Alpine apk add clang-dev
+ macOS xcode-select --install, or: brew install llvm
+ and set LIBCLANG_PATH=$(brew --prefix llvm)/lib
+
+LLVM is needed to build this crate, not to run what it builds.
+"
+ );
+}
+
/// `-isystem` arguments for the MSVC and Windows SDK header directories the
/// `cc` crate located, so libclang can resolve the C standard headers the
/// same way `cl.exe` does.
diff --git a/crates/libredwg-sys/shim/uncad_shim.c b/crates/libredwg-sys/shim/uncad_shim.c
index 9dc498c..87e1354 100644
--- a/crates/libredwg-sys/shim/uncad_shim.c
+++ b/crates/libredwg-sys/shim/uncad_shim.c
@@ -1,9 +1,19 @@
+#include
+#include
#include
#include
#include
#include "dwg.h"
#include "dwg_api.h"
+/* Private LibreDWG headers (vendor/libredwg/src, on the include path in
+ build.rs): bits.h for Bit_Chain and IS_FROM_TU_DWG; decode.h for
+ dwg_decode; in_dxf.h for dwg_read_dxf/dwg_read_dxfb; logging.h for the
+ `loglevel` global the file-based readers set from dwg->opts. */
+#include "bits.h"
+#include "decode.h"
+#include "logging.h" /* must precede in_dxf.h (logging.h enforces it) */
+#include "in_dxf.h"
#include "uncad_shim.h"
void *
@@ -35,6 +45,186 @@ uncad_object_object_ptr (Dwg_Object *obj)
return (void *)o->tio.UNKNOWN_OBJ;
}
+/* --- reading from memory ------------------------------------------------ */
+
+/* Copy of the file-static dwg_fixup_viewport_ids() in src/dwg.c, which
+ dwg_read_file() runs after a successful decode and which this shim's
+ memory-based reader must therefore run too: it gives every paper-space
+ VIEWPORT its on_off/id the way AutoCAD numbers them, with the layout's
+ own overall viewport (entmode 0) as id 0 / off. */
+static void
+fixup_viewport_ids (Dwg_Data *restrict dwg)
+{
+ BITCODE_BL i;
+ BITCODE_RS last_id = 0;
+ for (i = 0; i < dwg->num_objects; i++)
+ {
+ Dwg_Object *obj = &dwg->object[i];
+ if (obj->supertype == DWG_SUPERTYPE_ENTITY
+ && obj->fixedtype == DWG_TYPE_VIEWPORT)
+ {
+ Dwg_Entity_VIEWPORT *_obj = obj->tio.entity->tio.VIEWPORT;
+ if (obj->tio.entity->entmode == 0)
+ {
+ _obj->on_off = 0;
+ _obj->id = 0;
+ last_id = 0;
+ }
+ else
+ {
+ _obj->on_off = 1;
+ last_id++;
+ _obj->id = last_id;
+ }
+ }
+ }
+}
+
+int
+uncad_dwg_read_bytes (const unsigned char *buf, size_t len, Dwg_Data *dwg)
+{
+ Bit_Chain dat;
+ int error;
+ unsigned int opts;
+
+ if (!dwg)
+ return DWG_ERR_INVALIDDWG;
+ /* Same reset as dwg_read_file(): everything but the log-level bits. */
+ opts = dwg->opts & DWG_OPTS_LOGLEVEL;
+ loglevel = opts;
+ memset (dwg, 0, sizeof (Dwg_Data));
+ dwg->opts = opts;
+
+ if (!buf || len < 6)
+ return DWG_ERR_INVALIDDWG;
+
+ /* dat_read_file() leaves the chain NUL-terminated (size + 1 bytes); a
+ little extra zeroed slack costs nothing and protects the bit reader's
+ look-ahead on a truncated file. */
+ memset (&dat, 0, sizeof (Bit_Chain));
+ dat.chain = (unsigned char *)calloc (1, len + 16);
+ if (!dat.chain)
+ return DWG_ERR_OUTOFMEM;
+ memcpy (dat.chain, buf, len);
+ dat.size = len;
+ dat.opts = dwg->opts;
+
+ error = dwg_decode (&dat, dwg);
+ free (dat.chain);
+ if (error >= DWG_ERR_CRITICAL)
+ return error;
+
+ fixup_viewport_ids (dwg);
+ return error;
+}
+
+int
+uncad_dxf_read_bytes (const unsigned char *buf, size_t len, Dwg_Data *dwg)
+{
+ Bit_Chain dat;
+ int error;
+ unsigned int opts;
+ Dwg_Version_Type version;
+
+ if (!dwg)
+ return DWG_ERR_INVALIDDWG;
+ /* Same reset as dxf_read_file(): keep the log level and a caller-preset
+ target version. */
+ opts = dwg->opts & DWG_OPTS_LOGLEVEL;
+ loglevel = opts;
+ version = dwg->header.version;
+ memset (dwg, 0, sizeof (Dwg_Data));
+ dwg->opts = opts | DWG_OPTS_INDXF;
+ dwg->header.version = version;
+
+ /* "0\nSECTION\n2\nENTITIES\n0\nENDSEC\n" is the smallest DXF the reader
+ accepts; dxf_read_file() rejects anything shorter the same way. */
+ if (!buf || len < 31)
+ return DWG_ERR_IOERROR;
+
+ memset (&dat, 0, sizeof (Bit_Chain));
+ dat.chain = (unsigned char *)calloc (1, len + 2);
+ if (!dat.chain)
+ return DWG_ERR_OUTOFMEM;
+ memcpy (dat.chain, buf, len);
+ dat.size = len;
+ dat.from_version = dwg->header.from_version;
+ dat.version = dwg->header.version;
+ dat.opts = dwg->opts;
+
+ /* Terminate the buffer for the strtol()/sscanf() readers -- reproduced
+ verbatim from dxf_read_file(), including its quirk of writing the NUL
+ over the newline it just appended (the calloc'd slack keeps the result
+ NUL-terminated either way). */
+ if (dat.chain[len - 1] != '\n')
+ {
+ dat.chain[len] = '\n';
+ dat.size++;
+ }
+ dat.chain[len] = '\0';
+
+ /* Fail on DWG */
+ if (!memcmp (dat.chain, "AC10", 4) || !memcmp (dat.chain, "AC1.", 4)
+ || !memcmp (dat.chain, "AC2.10", 4) || !memcmp (dat.chain, "MC0.0", 4))
+ {
+ free (dat.chain);
+ return DWG_ERR_INVALIDDWG;
+ }
+ /* See if binary or ascii */
+ if (!memcmp (dat.chain, "AutoCAD Binary DXF",
+ sizeof ("AutoCAD Binary DXF") - 1))
+ {
+ dat.byte = 22;
+ error = dwg_read_dxfb (&dat, dwg);
+ }
+ else
+ error = dwg_read_dxf (&dat, dwg);
+
+ dwg->opts |= (DWG_OPTS_INDXF | opts);
+ free (dat.chain);
+ if (error >= DWG_ERR_CRITICAL)
+ return error;
+ return 0;
+}
+
+/* --- file header ---------------------------------------------------------- */
+
+int
+uncad_dwg_version (const Dwg_Data *dwg)
+{
+ return dwg ? (int)dwg->header.version : 0;
+}
+
+int
+uncad_dwg_from_version (const Dwg_Data *dwg)
+{
+ return dwg ? (int)dwg->header.from_version : 0;
+}
+
+int
+uncad_dwg_from_dxf (const Dwg_Data *dwg)
+{
+ return (dwg && (dwg->opts & DWG_OPTS_INDXF)) ? 1 : 0;
+}
+
+uint16_t
+uncad_dwg_numheader_vars (const Dwg_Data *dwg)
+{
+ return dwg ? (uint16_t)dwg->header.numheader_vars : 0;
+}
+
+int
+uncad_dwg_template_read (const Dwg_Data *dwg)
+{
+ /* template.spec reads `description` first, and bit_read_T16/TU16 allocate
+ it for every length, zero included: non-NULL exactly when the decoder
+ found and read the section. The DXF importer copies $MEASUREMENT into
+ the Template without it. */
+ return (dwg && dwg->Template.description) ? 1 : 0;
+}
+
+/* --- MULTILEADER ------------------------------------------------------------ */
+
unsigned int
uncad_multileader_get_lines (void *entity, uncad_multileader_line_t **out_lines)
{
@@ -103,6 +293,8 @@ uncad_multileader_free_lines (uncad_multileader_line_t *lines, unsigned int num_
free (lines);
}
+/* --- 3DSOLID ---------------------------------------------------------------- */
+
char *
uncad_3dsolid_sab_to_sat_text (const void *entity, size_t *out_len)
{
@@ -168,6 +360,8 @@ uncad_free_sat_text (char *text)
free (text);
}
+/* --- version bands, codepage and string width ---------------------------- */
+
int
uncad_dwg_is_pre_r13 (const Dwg_Data *dwg)
{
@@ -220,12 +414,8 @@ uncad_dwg_codepage (const Dwg_Data *dwg)
int
uncad_dwg_is_wide_string (const Dwg_Data *dwg)
{
- if (!dwg)
- return 0;
- /* The same test as the library's IS_FROM_TU_DWG (bits.h, an internal
- * header): read from an R2007+ DWG, and not through an importer
- * (DWG_OPTS_IN -- DXF/JSON input), whose strings are stored as given. */
- return (dwg->header.from_version >= R_2007 && !(dwg->opts & DWG_OPTS_IN))
- ? 1
- : 0;
+ /* The library's own IS_FROM_TU_DWG (bits.h, an internal header on this
+ * file's include path): read from an R2007+ DWG, and not through an
+ * importer (DWG_OPTS_IN -- DXF/JSON input). */
+ return (dwg && IS_FROM_TU_DWG (dwg)) ? 1 : 0;
}
diff --git a/crates/libredwg-sys/shim/uncad_shim.h b/crates/libredwg-sys/shim/uncad_shim.h
index 8391a72..94e2aa2 100644
--- a/crates/libredwg-sys/shim/uncad_shim.h
+++ b/crates/libredwg-sys/shim/uncad_shim.h
@@ -37,6 +37,60 @@ void *uncad_object_entity_ptr(Dwg_Object *obj);
*/
void *uncad_object_object_ptr(Dwg_Object *obj);
+/* --- reading from memory ------------------------------------------------
+ *
+ * dwg_read_file()/dxf_read_file() take a `const char *filename` and open it
+ * with fopen(). On Windows the MSVC C runtime interprets that byte string in
+ * the process's ANSI code page, so a UTF-8 path with non-ASCII characters
+ * (e.g. a Korean directory name) fails with DWG_ERR_IOERROR even though the
+ * file exists. Reading the bytes in Rust (std::fs::read handles Unicode
+ * paths on every platform) and decoding from memory sidesteps that, and is
+ * also what a server that already holds the file in memory wants.
+ *
+ * Both functions mirror the body of their file-based LibreDWG counterpart
+ * (src/dwg.c): `dwg` is cleared except for the log-level bits of its `opts`
+ * (and, for DXF, its `header.version`), the buffer is copied into a
+ * Bit_Chain LibreDWG owns for the duration of the decode, and the return
+ * value has the same meaning (0 or a DWG_ERROR bit set; >= DWG_ERR_CRITICAL
+ * means the decode failed). `buf` is only read, never retained.
+ */
+int uncad_dwg_read_bytes(const unsigned char *buf, size_t len, Dwg_Data *dwg);
+int uncad_dxf_read_bytes(const unsigned char *buf, size_t len, Dwg_Data *dwg);
+
+/* --- file header ----------------------------------------------------------
+ *
+ * Dwg_Data is opaque on the Rust side (see build.rs), and LibreDWG has no
+ * public accessor for these header fields, which together with the
+ * codepage (uncad_dwg_codepage, below) decide how the drawing's strings sit
+ * in memory and which header variables the file states: `version` is the
+ * version the drawing is held as, `from_version` the one it was read from,
+ * both Dwg_Version_Type enum values (dwg.h). Both return 0 for a NULL
+ * `dwg`.
+ */
+int uncad_dwg_version(const Dwg_Data *dwg);
+int uncad_dwg_from_version(const Dwg_Data *dwg);
+
+/* 1 when the data came from DXF text (LibreDWG's DWG_OPTS_INDXF flag on
+ * `dwg->opts`), 0 for a DWG or a NULL `dwg`. Some fields mean something
+ * else on that path (a LAYER's plot flag, for one), and an R2007+ DXF holds
+ * its strings differently from an R2007+ DWG (see uncad_dwg_is_wide_string).
+ */
+int uncad_dwg_from_dxf(const Dwg_Data *dwg);
+
+/* How many header variables a pre-R13 DWG's file header says its header
+ * section holds (`dwg->header.numheader_vars`: 74, 83, ... 205 across the
+ * releases), which decides where LibreDWG's pre-R13 header layout stops
+ * reading (header_variables_r11.spec). 0 for R13 and later, whose header
+ * layout is fixed per version, and for a NULL `dwg`. */
+uint16_t uncad_dwg_numheader_vars(const Dwg_Data *dwg);
+
+/* 1 when the decoder read a DWG's Template section -- the one that holds
+ * $MEASUREMENT, optional before R2007 and skipped without an error bit when
+ * it is missing -- 0 otherwise, for any DXF input and for a NULL `dwg`.
+ * Without this a $MEASUREMENT of 0 ("English") cannot be told from one the
+ * file never stated. */
+int uncad_dwg_template_read(const Dwg_Data *dwg);
+
/* One leader-line's vertices from a MULTILEADER, flattened into a single
* malloc'd (x,y,z) array -- see uncad_multileader_get_lines. */
typedef struct uncad_multileader_line
@@ -168,10 +222,14 @@ uint16_t uncad_dwg_codepage(const Dwg_Data *dwg);
/* 1 when the drawing's strings are wide (UTF-16, R2007 and later), which the
* library converts to UTF-8 in its text accessors, 0 otherwise or when `dwg`
- * is NULL. The same test as the library's own IS_FROM_TU_DWG (an internal
+ * is NULL. This is the library's own IS_FROM_TU_DWG (src/bits.h, an internal
* macro): `from_version` (the source's version) is R2007 or later and the
- * drawing did not come through an importer (DXF/JSON input keeps strings
- * as given).
+ * drawing did not come through an importer (DXF/JSON input). For a DWG, 0
+ * means the strings are the file's raw 8-bit code-page bytes. For a DXF it
+ * is 0 whatever the version, although an R2007+ DXF holds most of its T
+ * fields as UTF-16 in memory, which the accessors then hand out unconverted
+ * -- the Rust side (crates/uncad/src/text.rs) says which strings are which
+ * and reads each in its width.
*/
int uncad_dwg_is_wide_string(const Dwg_Data *dwg);
diff --git a/crates/libredwg-sys/src/lib.rs b/crates/libredwg-sys/src/lib.rs
index b1b0183..adba450 100644
--- a/crates/libredwg-sys/src/lib.rs
+++ b/crates/libredwg-sys/src/lib.rs
@@ -1,5 +1,6 @@
-//! Raw FFI bindings to the LibreDWG C sources (vendored, unmodified, from
-//! the real upstream project at `lib/libredwg`, GPLv3+; see
+//! Raw FFI bindings to the LibreDWG C sources (a vendored subset of the
+//! real upstream project at `lib/libredwg`, GPLv3+, carrying five local
+//! patches marked `uncad local patch`; see `NOTICE.md` in this crate and
//! docs/THIRD_PARTY_NOTICES.md).
//! Generated by `build.rs` via `bindgen` against the public
//! `dwg.h`/`dwg_api.h` headers plus this crate's own `uncad_*` C shims
diff --git a/crates/libredwg-sys/vendor-config/config.h b/crates/libredwg-sys/vendor-config/config.h
index 412d297..59c30df 100644
--- a/crates/libredwg-sys/vendor-config/config.h
+++ b/crates/libredwg-sys/vendor-config/config.h
@@ -1,6 +1,7 @@
/* Hand-authored config.h for a native build of the vendored LibreDWG C
- sources (crates/libredwg-sys/vendor/libredwg/, an unmodified subset of
- the lib/libredwg submodule), used in place of the autotools-generated
+ sources (crates/libredwg-sys/vendor/libredwg/, a subset of the
+ lib/libredwg submodule carrying the local patches NOTICE.md lists -- none
+ of them in this file), used in place of the autotools-generated
src/config.h.in output. See docs/ARCHITECTURE.md's "Build" section for
why this project builds LibreDWG via a direct `cc`-crate compile instead
of autotools/configure (Windows autotools friction).
diff --git a/crates/libredwg-sys/vendor/libredwg/src/common.c b/crates/libredwg-sys/vendor/libredwg/src/common.c
index 722e195..aba74eb 100644
--- a/crates/libredwg-sys/vendor/libredwg/src/common.c
+++ b/crates/libredwg-sys/vendor/libredwg/src/common.c
@@ -525,6 +525,38 @@ find_hv (BITCODE_H *entries, BITCODE_BS num_entries, BITCODE_RLL handle_value)
return -1; // not found
}
+/* --- uncad local patch, 2026-09-23 (NOTICE.md at the root of this crate,
+ "The two changes"; the full reasoning is in the project's docs/CAVEATS.md,
+ "Local patches to the vendored LibreDWG", which is not in the published
+ tarball) ---------------------------------------------------------------
+
+ Every caller of cvt_TIMEBLL() hands the struct tm straight to strftime()
+ (dec_macros.h's FIELD_TIMEBLL/FIELD_TIMERLL and the DECODER block in
+ header_variables.spec, which runs at any log level). Microsoft's UCRT
+ strftime() *validates* its struct tm: measured against ucrtbase.dll, a
+ tm_year outside [-1900, 8099], tm_mon outside [0, 11], tm_mday outside
+ [1, 31], tm_hour outside [0, 23], tm_min outside [0, 59] or tm_sec
+ outside [0, 60] trips the invalid-parameter handler, which fail-fasts the
+ whole process with 0xC0000409 -- reported as STATUS_STACK_BUFFER_OVERRUN
+ although nothing overran. That is below the FFI boundary, so no Rust
+ guard can catch it.
+
+ A TIMEBLL is two raw BLs off the bit stream (BITCODE_BL, unsigned 32-bit
+ days and milliseconds), so a corrupt or truncated DWG -- or any mutation
+ that shifts the header bit stream before TDUCREATE/TDUUPDATE -- reaches
+ here with a date the Julian-day arithmetic below turns into an
+ out-of-range tm_year/tm_mon/tm_hour. Upstream additionally leaves
+ tm_wday/tm_yday/tm_isdst at whatever the caller's stack held.
+
+ So: zero the struct first, and clamp every field into the range strftime
+ accepts before returning. A real drawing's date already satisfies every
+ bound, so no valid file's parse changes. The one visible difference is
+ the debug string for a >24h TDINDWG/TDUSRTIMER *duration*, whose hour is
+ now capped at 23 -- a LOG_TRACE line only (this crate never raises
+ loglevel), and strftime cannot print an hour above 23 anyway. */
+#define UNCAD_TM_CLAMP(v, lo, hi) \
+ ((v) < (lo) ? (lo) : ((v) > (hi) ? (hi) : (v)))
+
/* from my dwg11.c, 1995 - rurban */
struct tm *
cvt_TIMEBLL (struct tm *tm, BITCODE_TIMEBLL date)
@@ -534,6 +566,7 @@ cvt_TIMEBLL (struct tm *tm, BITCODE_TIMEBLL date)
#define TRUNC(n) (long)floor (n)
+ memset (tm, 0, sizeof (struct tm)); /* uncad local patch */
t = 0.864 * date.ms / 1000.0; /*t=1000000 = 1 day, means 86400 in seconds */
if (date.days > 2299161)
{
@@ -578,6 +611,16 @@ cvt_TIMEBLL (struct tm *tm, BITCODE_TIMEBLL date)
ss = t - (tm->tm_min * 60.0);
tm->tm_sec = (int)ss;
// sprintf (s, "%02d.%02d.%4d %02d:%02d:%05.2f", d, m, y, hh, mm, ss);
+ /* uncad local patch: keep the result inside strftime's accepted ranges */
+ tm->tm_sec = UNCAD_TM_CLAMP (tm->tm_sec, 0, 60);
+ tm->tm_min = UNCAD_TM_CLAMP (tm->tm_min, 0, 59);
+ tm->tm_hour = UNCAD_TM_CLAMP (tm->tm_hour, 0, 23);
+ tm->tm_mday = UNCAD_TM_CLAMP (tm->tm_mday, 1, 31);
+ tm->tm_mon = UNCAD_TM_CLAMP (tm->tm_mon, 0, 11);
+ tm->tm_year = UNCAD_TM_CLAMP (tm->tm_year, -1900, 8099);
+ tm->tm_wday = UNCAD_TM_CLAMP (tm->tm_wday, 0, 6);
+ tm->tm_yday = UNCAD_TM_CLAMP (tm->tm_yday, 0, 365);
+ tm->tm_isdst = 0;
return tm;
}
diff --git a/crates/libredwg-sys/vendor/libredwg/src/common_entity_data.spec b/crates/libredwg-sys/vendor/libredwg/src/common_entity_data.spec
index f8518b0..d6c40c4 100644
--- a/crates/libredwg-sys/vendor/libredwg/src/common_entity_data.spec
+++ b/crates/libredwg-sys/vendor/libredwg/src/common_entity_data.spec
@@ -430,6 +430,39 @@
LOG_TRACE (" color.flag: 0x%x\n", _ent->color.flag);
#endif
flags = _ent->color.flag;
+ /* --- uncad local patch (see docs/CAVEATS.md, "Local patches to the
+ vendored LibreDWG") ------------------------------------------------
+
+ The rgb block below used to come *after* the alpha block, so an
+ entity whose flag has both 0x80 (an rgb follows) and 0x20 (a
+ transparency follows) had its two BLs swapped: the colour landed in
+ alpha_raw and the transparency in rgb. LibreDWG's own standalone
+ bit_read_ENC() (bits.c) already reads rgb first; only this spec (and
+ the encoder that shares it) had the two the other way round.
+
+ Measured on test-data/2004/HatchG.dwg, whose HATCH 29F (flag 0xa0)
+ sits inside LWPOLYLINE 28D (flag 0x80, one BL, unambiguous). With
+ alpha first the HATCH read alpha_raw = 0xc21ae464 -- byte for byte
+ the LWPOLYLINE's rgb -- and rgb = 0x020000e5, which has exactly the
+ alpha_type<<24 | alpha shape every flag-0x20 entity in the corpus
+ shows (0x0200003a, 0x02000056, ...). Reading rgb first gives the
+ HATCH its boundary's colour and leaves a well-formed transparency.
+ Only the 0xa0 combination changes: with one of the two bits set
+ there is a single BL and the order cannot matter. */
+ if (flags & 0x40)
+ {
+#ifndef IS_ENCODER
+ FIELD_HANDLE (color.handle, 0, 430); // DBCOLOR 1E9F74 => 1F05B9 lgtm[cpp/use-after-free] codeql[cpp/use-after-free]
+#endif
+ }
+ else if (flags & 0x80) // and not a reference
+ {
+ DXF {
+ VALUE_BL (_ent->color.rgb & 0x00ffffff, 420);
+ } else {
+ FIELD_BLx (color.rgb, 420); // ODA bug, documented as BS
+ }
+ }
if (flags & 0x20)
{
#ifdef IS_ENCODER
@@ -452,20 +485,7 @@
FIELD_RC (color.alpha, 0);
}
}
- if (flags & 0x40)
- {
-#ifndef IS_ENCODER
- FIELD_HANDLE (color.handle, 0, 430); // DBCOLOR 1E9F74 => 1F05B9 lgtm[cpp/use-after-free] codeql[cpp/use-after-free]
-#endif
- }
- else if (flags & 0x80) // and not a reference
- {
- DXF {
- VALUE_BL (_ent->color.rgb & 0x00ffffff, 420);
- } else {
- FIELD_BLx (color.rgb, 420); // ODA bug, documented as BS
- }
- }
+ /* --- end uncad local patch ---------------------------------------- */
if ((flags & 0x41) == 0x41)
{
FIELD_TV (color.name, 430);
diff --git a/crates/libredwg-sys/vendor/libredwg/src/dwg.c b/crates/libredwg-sys/vendor/libredwg/src/dwg.c
index 1ea92f7..ca37ec0 100644
--- a/crates/libredwg-sys/vendor/libredwg/src/dwg.c
+++ b/crates/libredwg-sys/vendor/libredwg/src/dwg.c
@@ -2328,6 +2328,74 @@ dwg_find_table_control (Dwg_Data *restrict dwg, const char *restrict table)
return NULL;
}
+/* --- uncad local patch, 2026-09-23 (NOTICE.md at the root of this crate,
+ "The two changes"; the full reasoning is in the project's docs/CAVEATS.md,
+ "Local patches to the vendored LibreDWG", which is not in the published
+ tarball) ---------------------------------------------------------------
+
+ LibreDWG stores a T (text) field as UTF-16 whenever the *writer* side of
+ dynapi decided to: dynapi_set_helper() widens as soon as
+ dwg->header.version >= R_2007, with no look at where the data came from.
+ The *reader* side (dwg_dynapi_entity_utf8text, dwg_dynapi_handle_name)
+ converts back only under IS_FROM_TU_DWG(), which additionally demands
+ !(dwg->opts & DWG_OPTS_IN) -- i.e. it is false for DXF and JSON input.
+ bits.h admits the gap in its own comment on IS_FROM_TU:
+ "only if from r2007+ DWG. not JSON, DXF (FIXME TABLE.name)".
+
+ So for an R2007+ DXF a table record's "name" came out as the raw UTF-16
+ buffer and was read as an 8-bit C string, which stops at the first NUL:
+ "Tavolo 3" compares as "T". Every name->handle lookup the DXF reader
+ makes (in_dxf.c resolves the group 8 layer, the group 2 block name, the
+ linetype, text style, dimstyle, UCS, VPORT and APPID names through
+ dwg_find_tablehandle and dwg_find_dicthandle_objname below) therefore
+ failed for any name longer than one character, leaving entity->layer and
+ INSERT->block_header NULL.
+
+ UNCAD_IS_TU_DWG() is the predicate that storage actually follows, and
+ matches the rule the uncad crate reads these strings by
+ (crates/uncad/src/text.rs). uncad_record_name_utf8() is the single place that
+ reads a record's "name" through dynapi and decodes it when needed.
+
+ Scope: *only* fields dynapi itself wrote. The strings in_dxf.c stores
+ through dwg_add_u8_input() -- DICTIONARY.texts, LTYPE.dashes[].text --
+ follow IS_FROM_TU_DWG and really are 8-bit for DXF input, so
+ dwg_find_dictionary() and dwg_find_dicthandle() below keep the original
+ predicate; widening it there would make bit_convert_TU/bit_eq_TU read
+ past an 8-bit allocation. The one table record in_dxf.c creates with
+ dwg_add_u8_input, the initial *Model_Space BLOCK_HEADER, is re-encoded to
+ TU by in_dxf.c itself as soon as $ACADVER turns out to be R2007+. */
+#define UNCAD_IS_TU_DWG(dwg) \
+ (IS_FROM_TU_DWG (dwg) \
+ || (((dwg)->opts & DWG_OPTS_IN) && (dwg)->header.version >= R_2007))
+
+/* dwg_dynapi_entity_utf8text (_o, objname, "name", ...) plus the decode
+ IS_FROM_TU_DWG misses. On success *isnew tells the caller whether the
+ returned buffer must be freed, exactly as dynapi's own does. */
+static bool
+uncad_record_name_utf8 (const Dwg_Data *restrict dwg, void *restrict _o,
+ const char *restrict objname, char **restrict out,
+ int *restrict isnew)
+{
+ Dwg_DYNAPI_field fp;
+ char *utf8;
+
+ *isnew = 0;
+ if (!dwg_dynapi_entity_utf8text (_o, objname, "name", out, isnew, &fp))
+ return false;
+ /* Already decoded by dynapi, a NULL name, or a fixed-length TF field
+ (which dynapi_set_helper never widens): nothing to do. */
+ if (*isnew || !*out || strEQc (fp.type, "TF") || !UNCAD_IS_TU_DWG (dwg))
+ return true;
+ utf8 = bit_convert_TU ((BITCODE_TU) (void *)*out);
+ if (utf8)
+ {
+ *out = utf8;
+ *isnew = 1;
+ }
+ return true;
+}
+/* --- end uncad local patch --------------------------------------------- */
+
// Searching for a named dictionary entry, name is utf8.
// Returning a hardpointer ref (5) to it, as stored in header_vars.
// Usually another dictionary.
@@ -2461,8 +2529,8 @@ dwg_find_dicthandle_objname (Dwg_Data *restrict dwg, BITCODE_H dict,
|| !hobj->name)
continue;
_o = hobj->tio.object->tio.APPID;
- ok = dwg_dynapi_entity_utf8text (_o, hobj->name, "name", &hdlname,
- &isnew, NULL);
+ /* uncad local patch: was dwg_dynapi_entity_utf8text () */
+ ok = uncad_record_name_utf8 (dwg, _o, hobj->name, &hdlname, &isnew);
LOG_HANDLE (" %s.%s[%d] => %s.name: %s\n", obj->name, "entries", i,
hobj->name, hdlname ? hdlname : "NULL");
if (ok && hdlname
@@ -2777,8 +2845,8 @@ dwg_find_tablehandle (Dwg_Data *restrict dwg, const char *restrict name,
if (!hobj || !hobj->tio.object || !hobj->tio.object->tio.APPID)
continue;
_o = hobj->tio.object->tio.APPID;
- ok = dwg_dynapi_entity_utf8text (_o, hobj->name, "name", &hdlname,
- &isnew, NULL);
+ /* uncad local patch: was dwg_dynapi_entity_utf8text () */
+ ok = uncad_record_name_utf8 (dwg, _o, hobj->name, &hdlname, &isnew);
LOG_HANDLE (" %s.%s[%d] => %s.name: %s\n", obj->name, "entries", i,
hobj->name, hdlname ? hdlname : "NULL");
if (ok && hdlname
@@ -2999,8 +3067,8 @@ dwg_handle_name (Dwg_Data *restrict dwg, const char *restrict table,
name = bo->name;
}
}
- ok = dwg_dynapi_entity_utf8text (_o, name, "name", &hdlname, &isnew,
- NULL);
+ /* uncad local patch: was dwg_dynapi_entity_utf8text () */
+ ok = uncad_record_name_utf8 (dwg, _o, name, &hdlname, &isnew);
LOG_HANDLE (" %s.%s[%d] => %s.name: %s\n", obj->name, "entries", i,
hobj->name, hdlname ? hdlname : "NULL");
if (ok)
diff --git a/crates/libredwg-sys/vendor/libredwg/src/dynapi.c b/crates/libredwg-sys/vendor/libredwg/src/dynapi.c
index 7c55c38..013ab06 100644
--- a/crates/libredwg-sys/vendor/libredwg/src/dynapi.c
+++ b/crates/libredwg-sys/vendor/libredwg/src/dynapi.c
@@ -14744,7 +14744,12 @@ static const struct _name_subclasses dwg_name_subclasses[] = {
{ "VBA_PROJECT", {"AcDbObject", "AcDbVbaProject", NULL, NULL, NULL, NULL, NULL, NULL} },
{ "VERTEX_2D", {"AcDbEntity", "AcDbVertex", "AcDb2dVertex", NULL, NULL, NULL, NULL, NULL} },
{ "VERTEX_3D", {"AcDbEntity", "AcDbVertex", "AcDb3dPolylineVertex", NULL, NULL, NULL, NULL, NULL} },
- { "VERTEX_MESH", {"AcDbEntity", "AcDbVertex", "AcDbPolyFaceMeshVertex", NULL, NULL, NULL, NULL, NULL} },
+ /* uncad local patch: "AcDbPolygonMeshVertex" added -- see in_dxf.c's
+ VERTEX_2D upgrade chain and docs/CAVEATS.md. It is the subclass marker
+ AutoCAD writes on a polygon mesh's vertices, i.e. exactly what a
+ VERTEX_MESH is; without it dwg_has_subclass() rejects the object and the
+ DXF reader fails the whole file. */
+ { "VERTEX_MESH", {"AcDbEntity", "AcDbVertex", "AcDbPolyFaceMeshVertex", "AcDbPolygonMeshVertex", NULL, NULL, NULL, NULL} },
{ "VERTEX_PFACE", {"AcDbEntity", "AcDbVertex", "AcDbPolyFaceMeshVertex", NULL, NULL, NULL, NULL, NULL} },
{ "VERTEX_PFACE_FACE", {"AcDbEntity", "AcDbFaceRecord", NULL, NULL, NULL, NULL, NULL, NULL} },
{ "VIEW", {"AcDbSymbolTableRecord", "AcDbViewTableRecord", NULL, NULL, NULL, NULL, NULL, NULL} },
diff --git a/crates/libredwg-sys/vendor/libredwg/src/in_dxf.c b/crates/libredwg-sys/vendor/libredwg/src/in_dxf.c
index db846a7..6eab077 100644
--- a/crates/libredwg-sys/vendor/libredwg/src/in_dxf.c
+++ b/crates/libredwg-sys/vendor/libredwg/src/in_dxf.c
@@ -10524,6 +10524,32 @@ static __nonnull ((1, 2, 3, 4)) Dxf_Pair *new_object (
UPGRADE_ENTITY (VERTEX_2D, VERTEX_MESH)
}
}
+ /* --- uncad local patch (see docs/CAVEATS.md, "Local
+ patches to the vendored LibreDWG") ------------------
+
+ A polygon mesh's vertices carry the subclass marker
+ AcDbPolygonMeshVertex, which appeared nowhere in
+ LibreDWG: the chain above knows AcDb3dPolylineVertex,
+ AcDbPolyFaceMeshVertex and AcDbFaceRecord only. The
+ object therefore stayed a VERTEX_2D, whose subclass list
+ (dynapi.c) does not contain that name either, so the
+ "Illegal subclass ... in object VERTEX_2D" check below
+ took `goto invalid_dxf` -- DWG_ERR_INVALIDDWG, a
+ *critical* error, which makes the whole file
+ unreadable. One POLYLINE written by REVSURF, RULESURF,
+ EDGESURF, ezdxf's add_polymesh() or any terrain tool
+ cost every other entity in the DXF.
+
+ VERTEX_MESH is what the else-branch above already
+ upgrades a polygon mesh's vertices to, so this only
+ adds the spelling AutoCAD actually writes. Its entry in
+ dwg_name_subclasses[] (dynapi.c) gains the same name,
+ so the subclass check passes afterwards. */
+ else if (strEQc (subclass, "AcDbPolygonMeshVertex"))
+ {
+ UPGRADE_ENTITY (VERTEX_2D, VERTEX_MESH)
+ }
+ /* --- end uncad local patch ------------------------- */
else if (strEQc (subclass, "AcDbFaceRecord"))
{
UPGRADE_ENTITY (VERTEX_2D, VERTEX_PFACE_FACE)
diff --git a/crates/uncad-cli/Cargo.toml b/crates/uncad-cli/Cargo.toml
index 5ea79de..55a222a 100644
--- a/crates/uncad-cli/Cargo.toml
+++ b/crates/uncad-cli/Cargo.toml
@@ -22,6 +22,8 @@ path = "src/main.rs"
uncad = { path = "../uncad", version = "0.2.0" }
# SVG/PNG output. A version dependency, never a path.
iron-render-cad = "0.1.0"
+# The LLM/VLM package behind `uncad export`.
+uncad-export = { path = "../uncad-export", version = "0.1.0" }
[dev-dependencies]
serde_json = "1.0.151"
diff --git a/crates/uncad-cli/LICENSE b/crates/uncad-cli/LICENSE
new file mode 100644
index 0000000..94a9ed0
--- /dev/null
+++ b/crates/uncad-cli/LICENSE
@@ -0,0 +1,674 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ Copyright (C)
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+.
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+.
diff --git a/crates/uncad-cli/src/main.rs b/crates/uncad-cli/src/main.rs
index fe4c6fb..d1a57aa 100644
--- a/crates/uncad-cli/src/main.rs
+++ b/crates/uncad-cli/src/main.rs
@@ -4,7 +4,7 @@
//! the parsed model as JSON or a rendering of it as SVG/PNG. There is no
//! DWG/DXF output.
-use iron_render_cad::{to_png, to_svg, Space, ToPngOptions, ToSvgOptions};
+use iron_render_cad::{to_png, to_svg, Crop, PngSize, Space, ToPngOptions, ToSvgOptions};
use std::collections::BTreeMap;
use std::path::Path;
use std::process::ExitCode;
@@ -18,11 +18,14 @@ Usage:
uncad -o export the parsed model (entities + tables)
uncad -o render to SVG
uncad -o render to PNG (rasterized from the SVG)
+ uncad export -o write the LLM/VLM package (images + JSON)
JSON options:
--pretty indented, multi-line JSON (default: one line)
SVG/PNG options:
+ --include-hidden draw entities hidden by their layer or flag (off,
+ frozen, non-plotting, DEFPOINTS, invisible)
--space which space to render (default: model)
model = the drawing itself
paper = sheet borders and title blocks
@@ -34,6 +37,17 @@ PNG options:
--scale multiplies the SVG viewBox size (default: 1.0,
e.g. 2.0 for twice the resolution)
+Export options (uncad export):
+ --profile claude (default), claude-hires, openai-patch
+ --max-levels deepest tile level (default 5; 0 = overview only)
+ --max-tiles tile budget (default 400)
+ --no-sheets skip the paper-layout sheet images
+ --svg also write drawing.svg
+
+Both commands:
+ -h, --help this text
+ -V, --version print the version
+
Examples:
uncad drawing.dwg
uncad drawing.dwg -o drawing.json --pretty
@@ -48,10 +62,11 @@ struct Args {
outlier_trim: bool,
scale: String,
pretty: bool,
+ include_hidden: bool,
help: bool,
}
-fn parse_args(argv: &[String]) -> Args {
+fn parse_args(argv: &[String]) -> Result {
let mut args = Args {
input: None,
output: None,
@@ -59,41 +74,70 @@ fn parse_args(argv: &[String]) -> Args {
outlier_trim: true,
scale: "1".to_string(),
pretty: false,
+ include_hidden: false,
help: false,
};
let mut i = 0;
while i < argv.len() {
match argv[i].as_str() {
- "-o" | "--output" => {
+ "-o" | "--output" | "--space" | "--scale" => {
+ let flag = argv[i].as_str();
i += 1;
- args.output = argv.get(i).cloned();
- }
- "--space" => {
- i += 1;
- if let Some(v) = argv.get(i) {
- args.space = v.clone();
+ let value = argv
+ .get(i)
+ .cloned()
+ .ok_or_else(|| format!("{flag} needs a value"))?;
+ match flag {
+ "--space" => args.space = value,
+ "--scale" => args.scale = value,
+ _ => args.output = Some(value),
}
}
"--no-trim" => args.outlier_trim = false,
- "--scale" => {
- i += 1;
- if let Some(v) = argv.get(i) {
- args.scale = v.clone();
- }
- }
"--pretty" => args.pretty = true,
+ "--include-hidden" => args.include_hidden = true,
"-h" | "--help" => args.help = true,
- other if args.input.is_none() => args.input = Some(other.to_string()),
- _ => {}
+ // Answered by `main` before any parsing.
+ "-V" | "--version" => {}
+ flag if flag.starts_with('-') && flag.len() > 1 => {
+ return Err(format!("unknown option '{flag}' (see --help)"));
+ }
+ positional => {
+ if let Some(first) = &args.input {
+ return Err(format!(
+ "unexpected argument '{positional}': the input is already '{first}'"
+ ));
+ }
+ args.input = Some(positional.to_string());
+ }
}
i += 1;
}
- args
+ Ok(args)
}
fn main() -> ExitCode {
let argv: Vec = std::env::args().skip(1).collect();
- let args = parse_args(&argv);
+ if argv.iter().any(|a| a == "-V" || a == "--version") {
+ println!("uncad {}", env!("CARGO_PKG_VERSION"));
+ return ExitCode::SUCCESS;
+ }
+ if argv.first().map(String::as_str) == Some("export") {
+ return match run_export(&argv[1..]) {
+ Ok(()) => ExitCode::SUCCESS,
+ Err(message) => {
+ eprintln!("error: {message}");
+ ExitCode::FAILURE
+ }
+ };
+ }
+ let args = match parse_args(&argv) {
+ Ok(args) => args,
+ Err(message) => {
+ eprintln!("error: {message}");
+ return ExitCode::FAILURE;
+ }
+ };
if args.help || args.input.is_none() {
eprintln!("{USAGE}");
@@ -157,7 +201,8 @@ fn run(args: &Args) -> Result<(), String> {
&db,
ToPngOptions {
svg: svg_options(args)?,
- scale: parse_scale(&args.scale)?,
+ size: PngSize::Scale(parse_scale(&args.scale)?),
+ ..ToPngOptions::default()
},
)
.map_err(|e| e.to_string())?;
@@ -187,10 +232,11 @@ fn run(args: &Args) -> Result<(), String> {
Ok(())
}
-/// `uncad::parse()` reports a nonexistent path or a wrong extension as a bare
-/// LibreDWG error code, which is accurate but not something a user can act on
-/// without cross-referencing dwg.h. The obvious cases are checked here first so
-/// the message says what is actually wrong.
+/// `uncad::parse()` reports a path it cannot read as `ParseError::Io`, and a
+/// file that is not a drawing as a bare LibreDWG error code, which is accurate
+/// but not something a user can act on without cross-referencing dwg.h. The
+/// obvious cases are checked here first so the message says what is actually
+/// wrong.
fn parse_input(input: &str) -> Result {
match std::fs::metadata(input) {
Ok(meta) if meta.is_dir() => {
@@ -210,7 +256,12 @@ fn write_output(path: &str, bytes: &[u8]) -> Result<(), String> {
fn svg_options(args: &Args) -> Result {
Ok(ToSvgOptions {
space: parse_space(&args.space)?,
- outlier_trim: args.outlier_trim,
+ crop: if args.outlier_trim {
+ Crop::Cluster
+ } else {
+ Crop::Everything
+ },
+ include_hidden: args.include_hidden,
..Default::default()
})
}
@@ -250,3 +301,79 @@ fn print_summary(input: &str, db: &CadDatabase) {
println!(" {type_name}: {count}");
}
}
+
+/// `uncad export -o [options]`: the LLM/VLM package through
+/// `uncad-export`. An option this subcommand does not know is an error, not
+/// something silently ignored.
+fn run_export(argv: &[String]) -> Result<(), String> {
+ let mut input: Option<&str> = None;
+ let mut output: Option<&str> = None;
+ let mut options = uncad_export::ExportOptions::default();
+ let mut i = 0;
+ let value = |i: usize, flag: &str| -> Result<&str, String> {
+ argv.get(i + 1)
+ .map(String::as_str)
+ .ok_or_else(|| format!("{flag} needs a value"))
+ };
+ let count = |v: &str, flag: &str| -> Result {
+ v.parse::()
+ .map_err(|_| format!("{flag} takes a whole number, not '{v}'"))
+ };
+ while i < argv.len() {
+ match argv[i].as_str() {
+ "-o" | "--output" => {
+ output = Some(value(i, "-o")?);
+ i += 1;
+ }
+ "--profile" => {
+ let name = value(i, "--profile")?;
+ options.profile = uncad_export::Profile::by_name(name)
+ .ok_or_else(|| format!("unknown profile '{name}'"))?;
+ i += 1;
+ }
+ "--max-levels" => {
+ options.max_levels = count(value(i, "--max-levels")?, "--max-levels")? as u32;
+ i += 1;
+ }
+ "--max-tiles" => {
+ options.max_tiles = count(value(i, "--max-tiles")?, "--max-tiles")?;
+ i += 1;
+ }
+ "--no-sheets" => options.sheets = false,
+ "--svg" => options.svg = true,
+ "-h" | "--help" => {
+ eprintln!("{USAGE}");
+ return Ok(());
+ }
+ flag if flag.starts_with('-') => {
+ return Err(format!("unknown option '{flag}' for uncad export"));
+ }
+ positional => {
+ if let Some(first) = input {
+ return Err(format!(
+ "unexpected argument '{positional}': the input is already '{first}'"
+ ));
+ }
+ input = Some(positional);
+ }
+ }
+ i += 1;
+ }
+ let input = input.ok_or("uncad export needs an input drawing")?;
+ let output = output.ok_or("uncad export needs -o ")?;
+ let report = uncad_export::export_file(Path::new(input), Path::new(output), &options)
+ .map_err(|e| e.to_string())?;
+ for warning in &report.warnings {
+ eprintln!("warning: {warning}");
+ }
+ println!(
+ "wrote: {} ({} files; overview {}x{} px; {} frames; {} sheets)",
+ report.dir.display(),
+ report.files.len(),
+ report.overview.px[0],
+ report.overview.px[1],
+ report.frames.len(),
+ report.sheets.len()
+ );
+ Ok(())
+}
diff --git a/crates/uncad-cli/tests/documented_invocations.rs b/crates/uncad-cli/tests/documented_invocations.rs
index 7c271e3..d15b93b 100644
--- a/crates/uncad-cli/tests/documented_invocations.rs
+++ b/crates/uncad-cli/tests/documented_invocations.rs
@@ -427,3 +427,99 @@ fn no_trim_keeps_the_outlier_inside_the_viewbox() {
"--no-trim should keep the outlier inside the viewBox: {vb_untrimmed}"
);
}
+
+/// `uncad export -o ` writes a finished package (its
+/// manifest is the last file written) and refuses an option it does not
+/// know rather than ignoring it.
+#[test]
+fn export_writes_a_package_and_refuses_unknown_options() {
+ let input = concat!(
+ env!("CARGO_MANIFEST_DIR"),
+ "/../../lib/libredwg/test/test-data/example_2000.dwg"
+ );
+ let dir = std::env::temp_dir().join(format!("uncad-cli-export-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ let out = std::process::Command::new(EXE)
+ .args(["export", input, "-o"])
+ .arg(&dir)
+ .args(["--max-levels", "0"])
+ .output()
+ .expect("the binary runs");
+ assert!(
+ out.status.success(),
+ "{}",
+ String::from_utf8_lossy(&out.stderr)
+ );
+ assert!(dir.join("manifest.json").is_file());
+ assert!(dir.join("overview.png").is_file());
+ let _ = std::fs::remove_dir_all(&dir);
+
+ let bad = std::process::Command::new(EXE)
+ .args(["export", input, "--bogus"])
+ .output()
+ .expect("the binary runs");
+ assert!(!bad.status.success());
+ assert!(String::from_utf8_lossy(&bad.stderr).contains("unknown option '--bogus'"));
+}
+
+/// `--version` answers on both commands; an option the plain command does
+/// not know, a missing value and a second input are errors, not ignored.
+#[test]
+fn version_and_the_parsers_refusals() {
+ let run = |args: &[&str]| {
+ std::process::Command::new(EXE)
+ .args(args)
+ .output()
+ .expect("the binary runs")
+ };
+ let v = run(&["--version"]);
+ assert!(v.status.success());
+ assert_eq!(
+ String::from_utf8_lossy(&v.stdout).trim(),
+ format!("uncad {}", env!("CARGO_PKG_VERSION"))
+ );
+ assert!(run(&["export", "--version"]).status.success());
+ for (args, needle) in [
+ (&["x.dwg", "--bogus"][..], "unknown option '--bogus'"),
+ (&["--bogus", "x.dwg"][..], "unknown option '--bogus'"),
+ (&["x.dwg", "y.dwg"][..], "unexpected argument 'y.dwg'"),
+ (&["x.dwg", "-o"][..], "-o needs a value"),
+ ] {
+ let out = run(args);
+ assert!(!out.status.success(), "{args:?} should fail");
+ let err = String::from_utf8_lossy(&out.stderr);
+ assert!(err.contains(needle), "{args:?}: {err}");
+ }
+}
+
+/// `--include-hidden` draws what the layer rules leave out, so the SVG of a
+/// drawing with hidden entities grows.
+#[test]
+fn include_hidden_draws_more() {
+ let input = concat!(
+ env!("CARGO_MANIFEST_DIR"),
+ "/../uncad/tests/fixtures/hidden_layers_r2000.dxf"
+ );
+ let dir = std::env::temp_dir().join(format!("uncad-cli-hidden-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let render = |name: &str, extra: &[&str]| {
+ let path = dir.join(name);
+ let out = std::process::Command::new(EXE)
+ .arg(input)
+ .arg("-o")
+ .arg(&path)
+ .args(extra)
+ .output()
+ .expect("the binary runs");
+ assert!(
+ out.status.success(),
+ "{}",
+ String::from_utf8_lossy(&out.stderr)
+ );
+ std::fs::read_to_string(&path).unwrap()
+ };
+ let plain = render("plain.svg", &[]);
+ let all = render("all.svg", &["--include-hidden"]);
+ assert!(all.len() > plain.len(), "{} vs {}", all.len(), plain.len());
+ let _ = std::fs::remove_dir_all(&dir);
+}
diff --git a/crates/uncad-cli/tests/release_invariants.rs b/crates/uncad-cli/tests/release_invariants.rs
new file mode 100644
index 0000000..1c42563
--- /dev/null
+++ b/crates/uncad-cli/tests/release_invariants.rs
@@ -0,0 +1,238 @@
+//! What has to be true of the repository before a `cargo publish`.
+//!
+//! **Licence files reach the tarball.** `cargo package` never includes a file
+//! from outside the crate directory, so the repository root's `LICENSE` is
+//! not enough: every published crate needs its own copy (GPLv3 §4), and the
+//! modified vendored LibreDWG needs its modification notice inside the crate
+//! too (GPLv3 §5(a)). Both were missing from 0.2.0 -- `uncad-cli-0.2.0.crate`
+//! shipped no licence text at all -- and were verified only by someone
+//! remembering.
+//!
+//! Like `documented_invocations.rs`, this reads the repository it is checked
+//! into: run it from the workspace, not from an unpacked crate tarball.
+
+use std::fs;
+use std::path::{Path, PathBuf};
+
+/// The four published crates, in workspace order.
+const MEMBERS: [&str; 4] = ["libredwg-sys", "uncad", "uncad-export", "uncad-cli"];
+
+fn repo_root() -> PathBuf {
+ Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("../..")
+ .canonicalize()
+ .expect("the workspace root is two levels above this crate")
+}
+
+fn read(path: impl AsRef) -> String {
+ let path = path.as_ref();
+ fs::read_to_string(path).unwrap_or_else(|e| panic!("{}: {e}", path.display()))
+}
+
+/// Every crate a `cargo publish` uploads carries the GPLv3 text itself.
+///
+/// `cargo package --list` is the real check, but it needs the crates.io index
+/// and a clean VCS state, so it cannot run here. The property it would prove
+/// is this one: the file is inside the crate directory, which is exactly what
+/// decides whether the tarball has it.
+#[test]
+fn every_published_crate_ships_the_gpl_text() {
+ let root = repo_root();
+ let canonical = read(root.join("LICENSE"));
+ assert!(
+ canonical.contains("GNU GENERAL PUBLIC LICENSE")
+ && canonical.contains("Version 3, 29 June 2007"),
+ "the repository's LICENSE should be the GPLv3 text"
+ );
+
+ for member in MEMBERS {
+ let path = root.join("crates").join(member).join("LICENSE");
+ assert!(
+ path.exists(),
+ "crates/{member}/LICENSE is missing: `cargo package` only includes files under \
+ the crate directory, so this crate would be published with no licence text"
+ );
+ assert_eq!(
+ read(&path),
+ canonical,
+ "crates/{member}/LICENSE has drifted from the repository's LICENSE"
+ );
+ }
+}
+
+/// The bundled third-party source keeps its own licence beside it: LibreDWG's
+/// `COPYING` in `libredwg-sys`.
+#[test]
+fn the_vendored_libredwg_ships_its_own_licence() {
+ let copying = read(repo_root().join("crates/libredwg-sys/vendor/libredwg/COPYING"));
+ assert!(
+ copying.contains("GNU GENERAL PUBLIC LICENSE"),
+ "LibreDWG's own COPYING should stay beside the vendored sources"
+ );
+}
+
+/// The font `uncad-export` bundles is not GPL but OFL-1.1, and cargo-deny
+/// never sees a font: the licence has to travel beside the font inside the
+/// crate (the OFL asks for it with every copy), and the crate's licence
+/// expression has to say so, or crates.io would describe the tarball as GPL
+/// alone.
+#[test]
+fn the_bundled_font_ships_its_own_licence_and_the_manifest_names_it() {
+ let root = repo_root();
+ let crate_dir = root.join("crates/uncad-export");
+ assert!(
+ crate_dir.join("fonts/UncadSans-Regular.otf").exists(),
+ "the bundled font"
+ );
+ let ofl = read(crate_dir.join("fonts/OFL-NotoSansKR.txt"));
+ assert!(
+ ofl.contains("SIL OPEN FONT LICENSE Version 1.1"),
+ "the bundled font's OFL text should travel with the font"
+ );
+ let manifest = read(crate_dir.join("Cargo.toml"));
+ assert!(
+ manifest
+ .lines()
+ .any(|line| line.trim() == r#"license = "GPL-3.0-or-later AND OFL-1.1""#),
+ "crates/uncad-export/Cargo.toml should declare both licences"
+ );
+ let notices = read(root.join("docs/THIRD_PARTY_NOTICES.md"));
+ assert!(
+ notices.contains("crates/uncad-export/fonts/OFL-NotoSansKR.txt"),
+ "docs/THIRD_PARTY_NOTICES.md should name the font's licence"
+ );
+}
+
+/// Every vendored LibreDWG file carrying an `uncad local patch` marker, as
+/// its `/`-separated path under `vendor/libredwg`, found by walking the tree
+/// rather than trusting a list.
+fn patched_vendor_files(root: &Path) -> Vec {
+ fn walk(dir: &Path, vendor: &Path, out: &mut Vec) {
+ for entry in fs::read_dir(dir).expect("the vendored tree is readable") {
+ let path = entry.expect("a readable directory entry").path();
+ if path.is_dir() {
+ walk(&path, vendor, out);
+ } else if fs::read(&path)
+ .expect("a readable vendored file")
+ .windows(b"uncad local patch".len())
+ .any(|w| w == b"uncad local patch")
+ {
+ out.push(
+ path.strip_prefix(vendor)
+ .expect("inside the vendored tree")
+ .to_string_lossy()
+ .replace('\\', "/"),
+ );
+ }
+ }
+ }
+ let vendor = root.join("crates/libredwg-sys/vendor/libredwg");
+ let mut out = Vec::new();
+ walk(&vendor, &vendor, &mut out);
+ out.sort();
+ out
+}
+
+/// The comment a source file opens with -- its `//!` lines, or its first
+/// `/* ... */` block: what a reader of that file is told it is.
+fn leading_comment(text: &str) -> String {
+ if text.starts_with("//!") {
+ return text
+ .lines()
+ .take_while(|line| line.starts_with("//!"))
+ .collect::>()
+ .join("\n");
+ }
+ assert!(
+ text.starts_with("/*"),
+ "the file should open with a comment"
+ );
+ let end = text.find("*/").expect("the opening comment is closed");
+ text[..end].to_string()
+}
+
+/// Nothing may call the vendored LibreDWG unmodified while it carries local
+/// patches -- not the crate description crates.io shows, not the crate's own
+/// documentation, and not the project's documents. GPLv3 §5(a) asks for the
+/// opposite, and every one of these used to say it.
+#[test]
+fn nothing_describes_the_patched_libredwg_as_unmodified() {
+ let root = repo_root();
+ let patched = patched_vendor_files(&root);
+ assert!(
+ !patched.is_empty(),
+ "expected the vendored LibreDWG to carry `uncad local patch` markers; if the \
+ patches were dropped, the claims this test guards have to be revisited too"
+ );
+
+ // What the crate says about itself: the description crates.io shows, the
+ // top of the rustdoc, and the config.h header that sits beside the tree.
+ let manifest = read(root.join("crates/libredwg-sys/Cargo.toml"));
+ let description = manifest
+ .lines()
+ .find(|line| line.starts_with("description ="))
+ .expect("libredwg-sys declares a description");
+ let rustdoc = leading_comment(&read(root.join("crates/libredwg-sys/src/lib.rs")));
+ let config_h = leading_comment(&read(
+ root.join("crates/libredwg-sys/vendor-config/config.h"),
+ ));
+ for (what, text) in [
+ ("the crates.io description", description.to_string()),
+ ("src/lib.rs's crate documentation", rustdoc),
+ ("vendor-config/config.h's header", config_h),
+ ] {
+ assert!(
+ !text.contains("unmodified"),
+ "{what} calls the vendored LibreDWG unmodified, but {patched:?} carry local \
+ patches:\n{text}"
+ );
+ assert!(
+ text.contains("patch"),
+ "{what} should say the vendored LibreDWG carries local patches:\n{text}"
+ );
+ }
+
+ // The project's documents: each once described the vendored copy with one
+ // of these phrases.
+ for (doc, wrong) in [
+ ("README.md", "holds the unmodified"),
+ ("docs/ARCHITECTURE.md", "unmodified (see"),
+ ("docs/THIRD_PARTY_NOTICES.md", "unmodified subset"),
+ (
+ "docs/THIRD_PARTY_NOTICES.md",
+ "an unmodified, byte-for-byte copy",
+ ),
+ (
+ "docs/THIRD_PARTY_NOTICES.md",
+ "**Modified**: no — built directly",
+ ),
+ ] {
+ let text = read(root.join(doc));
+ assert!(
+ !text.contains(wrong),
+ "{doc} says \"{wrong}\" of the vendored LibreDWG while {patched:?} carry local \
+ patches"
+ );
+ assert!(
+ text.contains("local patch"),
+ "{doc} should say the vendored LibreDWG carries local patches"
+ );
+ }
+
+ // The markers point readers at NOTICE.md, which is inside the tarball
+ // (docs/ is not), and at docs/CAVEATS.md for the reasoning: both have to
+ // name every patched file.
+ let notice = read(root.join("crates/libredwg-sys/NOTICE.md"));
+ let caveats = read(root.join("docs/CAVEATS.md"));
+ for file in &patched {
+ assert!(
+ notice.contains(&format!("vendor/libredwg/{file}")),
+ "crates/libredwg-sys/NOTICE.md should name the patched vendor/libredwg/{file}"
+ );
+ assert!(
+ caveats.contains(&format!("`{file}`")),
+ "docs/CAVEATS.md's \"Local patches to the vendored LibreDWG\" should name the \
+ patched {file}"
+ );
+ }
+}
diff --git a/crates/uncad-export/Cargo.toml b/crates/uncad-export/Cargo.toml
new file mode 100644
index 0000000..4309a56
--- /dev/null
+++ b/crates/uncad-export/Cargo.toml
@@ -0,0 +1,36 @@
+[package]
+name = "uncad-export"
+version = "0.1.0"
+edition.workspace = true
+rust-version.workspace = true
+# The crate is GPL like the rest of this workspace (it links `uncad`, and so
+# LibreDWG); the font it bundles is data under the SIL Open Font License.
+# cargo-deny reads this expression as one license decision per crate --
+# see deny.toml -- and never looks at the font file itself, so the OFL is
+# stated here and in docs/THIRD_PARTY_NOTICES.md.
+license = "GPL-3.0-or-later AND OFL-1.1"
+repository.workspace = true
+homepage.workspace = true
+readme = "README.md"
+description = "An LLM/VLM-readable package of a DWG/DXF drawing: sized images, a tile pyramid, sheets per paper layout and exact JSON records, built on uncad and iron-render-cad."
+keywords = ["cad", "dwg", "dxf", "llm", "vision"]
+categories = ["graphics", "multimedia::images"]
+
+[lints]
+workspace = true
+
+[dependencies]
+# The parser, and the drawing's header (units, extents, the dimension
+# variables), which the model does not carry.
+uncad = { path = "../uncad", version = "0.2.0" }
+# The model and the renderer. Version dependencies, never paths: this crate
+# must build from a standalone checkout.
+uncad-model = "0.1.0"
+iron-render-cad = "0.1.0"
+serde = { version = "1.0.229", features = ["derive"] }
+serde_json = "1.0.151"
+# The package's images are 8-bit RGB; the renderer's encoder writes RGBA.
+# Already in the renderer's own tree (tiny-skia encodes through it).
+png = "0.18"
+# NFKC for the package's string index (㎡ -> m2, full-width digits, ...).
+unicode-normalization = "0.1"
diff --git a/crates/uncad-export/LICENSE b/crates/uncad-export/LICENSE
new file mode 100644
index 0000000..94a9ed0
--- /dev/null
+++ b/crates/uncad-export/LICENSE
@@ -0,0 +1,674 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ Copyright (C)
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+.
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+.
diff --git a/crates/uncad-export/README.md b/crates/uncad-export/README.md
new file mode 100644
index 0000000..5e9c783
--- /dev/null
+++ b/crates/uncad-export/README.md
@@ -0,0 +1,18 @@
+# uncad-export
+
+A DWG/DXF drawing as a package an LLM or a vision model can read: an
+overview image sized to the model's patch budget, a pyramid of overlapping
+tiles with JSON sidecars saying what is on each, one image per paper layout,
+and JSON records with the exact numbers a picture cannot give -- lengths,
+areas, dimension values, texts -- each pointing at the pixels it is drawn in.
+
+It is built on [`uncad`](https://crates.io/crates/uncad), which parses the
+drawing, and [`iron-render-cad`](https://crates.io/crates/iron-render-cad),
+which draws it. See the crate documentation for the package's layout.
+
+## Licence
+
+GPL-3.0-or-later (see `LICENSE`), like the rest of this repository. The
+bundled font `fonts/UncadSans-Regular.otf` is a Noto Sans KR subset under the
+SIL Open Font License 1.1 (see `fonts/OFL-NotoSansKR.txt` and
+`fonts/README.md`).
diff --git a/crates/uncad-export/fonts/OFL-NotoSansKR.txt b/crates/uncad-export/fonts/OFL-NotoSansKR.txt
new file mode 100644
index 0000000..1c9f432
--- /dev/null
+++ b/crates/uncad-export/fonts/OFL-NotoSansKR.txt
@@ -0,0 +1,93 @@
+Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/crates/uncad-export/fonts/README.md b/crates/uncad-export/fonts/README.md
new file mode 100644
index 0000000..9c553b0
--- /dev/null
+++ b/crates/uncad-export/fonts/README.md
@@ -0,0 +1,75 @@
+# Bundled font: Uncad Sans
+
+`UncadSans-Regular.otf` (370 KB) is a subset of **Noto Sans KR Regular
+v2.004** (Adobe / Google, SIL Open Font License 1.1 -- `OFL-NotoSansKR.txt`
+is the licence with its copyright notice). It is what the package's images
+and its text measurements are drawn with (`uncad_export::fonts::bundled()`,
+the default `ExportOptions::fonts`), so a drawing renders the same on every
+machine and a Hangul label never depends on the host's fonts. The renderer
+this crate draws with, `iron-render-cad`, bundles no font of its own: the
+bytes are handed to it as `Fonts::Custom`, with the face's capital height
+(`uncad_export::fonts::UNCAD_SANS_CAP_HEIGHT`, OS/2 `sCapHeight` 733 over
+`unitsPerEm` 1000) as `ToSvgOptions::cap_height`.
+
+Coverage (2755 glyphs): Basic Latin, Latin-1 Supplement, the part of Latin
+Extended-A Noto Sans KR has, Greek, General Punctuation, Hangul Compatibility
+Jamo, the 2350 KS X 1001 Hangul syllables, and the CAD symbols `∅ ° ± ² ³ ×
+Ø ㎡ ㎜ ㎥ ㎝ ㎞ ← ↑ → ↓ φ Δ Ω`. Hanja, the other 8822 syllables and `⌀`
+(U+2300, absent from Noto Sans KR itself) are not included: such a character
+is drawn as a crossed `.notdef` box (the subset keeps the notdef outline, see
+below) and counted in the package's `unshaped_glyphs` / `UnshapedGlyphs`
+warning. The renderer draws AutoCAD's `%%c` as U+2300, so a diameter written
+that way is one such box in the images; the package's own text records read
+it as `∅` (U+2205), which the subset has.
+
+There is no "this font first, then the host's" choice: `iron-render-cad`
+draws with the fonts it is given or with the host's, not both. A caller that
+wants the host's fonts passes `Fonts::System` and gives up the same-pixels
+guarantee.
+
+## Why this font
+
+Only Noto Sans KR and Pretendard cover every block above (NanumGothic, IBM
+Plex Sans KR and Spoqa Han Sans lack `Ø`, `∅`, `㎡` or Greek; Gowun Dodum
+subsets to 1.2 MB). Noto is the canonical, most widely mirrored face and
+its OFL Reserved Font Name is `Source`, which the subset's name does not
+use. The subset is renamed to `Uncad Sans` anyway: the OFL asks a Modified
+Version not to pass as the original, and a unique family name keeps a
+font database from picking a host-installed Noto Sans KR over the bundled
+face.
+
+## Reproducing the file
+
+Source: https://github.com/notofonts/noto-cjk/raw/main/Sans/SubsetOTF/KR/NotoSansKR-Regular.otf
+(4,644,748 bytes). Tools: Python 3 with `fonttools` and `brotli`
+(`uv venv venv && uv pip install --python venv/Scripts/python.exe fonttools brotli`).
+
+```text
+python tools/gen_unicodes.py # rewrites tools/unicodes.txt (2365 lines)
+set PYTHONUTF8=1
+pyftsubset NotoSansKR-Regular.otf --unicodes-file=tools/unicodes.txt \
+ --output-file=NotoSansKR-Regular-sub.otf --name-IDs='*' --notdef-outline --layout-features=''
+python tools/rename.py NotoSansKR-Regular-sub.otf UncadSans-Regular.otf "Uncad Sans" UncadSans-Regular
+```
+
+`gen_unicodes.py` writes next to itself, whatever directory it is run
+from, so it rewrites the `tools/unicodes.txt` checked in here -- the file
+`pyftsubset` reads -- and the rewrite is byte for byte the same file.
+
+`--layout-features=''` drops kerning and the other GSUB/GPOS tables (16 KB
+of kerning would keep glyph spacing identical to full Noto; the text
+measurement lays out whatever is bundled, so records and pixels agree either
+way). `--name-IDs='*'` keeps the copyright and licence name records; the
+rename script changes the family, full and PostScript names and appends
+`uncad subset` to the version string, as the OFL asks of a Modified Version.
+
+## Licence
+
+The font is OFL 1.1 (not GPL like the rest of this repository), which is
+why this crate's licence expression is `GPL-3.0-or-later AND OFL-1.1`.
+Bundling it in GPL software is permitted by the OFL; it may not be sold by
+itself, and the licence text must travel with it -- `cargo package`
+includes this directory, and every binary linking `uncad-export` embeds the
+font bytes. cargo-deny does not see a font, so the repository's
+`docs/THIRD_PARTY_NOTICES.md` names it, and `uncad-cli`'s
+`tests/release_invariants.rs` checks that the licence text is beside it.
diff --git a/crates/uncad-export/fonts/UncadSans-Regular.otf b/crates/uncad-export/fonts/UncadSans-Regular.otf
new file mode 100644
index 0000000..17022a2
Binary files /dev/null and b/crates/uncad-export/fonts/UncadSans-Regular.otf differ
diff --git a/crates/uncad-export/fonts/tools/gen_unicodes.py b/crates/uncad-export/fonts/tools/gen_unicodes.py
new file mode 100644
index 0000000..bd527a9
--- /dev/null
+++ b/crates/uncad-export/fonts/tools/gen_unicodes.py
@@ -0,0 +1,22 @@
+import os
+ks = [cp for cp in range(0xAC00, 0xD7A4) if len(chr(cp).encode('euc_kr')) == 2]
+assert len(ks) == 2350, len(ks)
+lines = [
+ "# Basic Latin", "U+0020-007E",
+ "# Latin-1 Supplement (degree B0, plus-minus B1, sup2 B2, sup3 B3, times D7, Oslash D8, oslash F8)", "U+00A0-00FF",
+ "# Latin Extended-A", "U+0100-017F",
+ "# Greek and Coptic (phi, delta, omega, ...)", "U+0370-03FF",
+ "# General Punctuation (primes, dashes, bullets)", "U+2000-206F",
+ "# Hangul Compatibility Jamo", "U+3130-318F",
+ "# CAD symbols: empty set (diameter), diameter sign, arrows, ㎡ ㎜ ㎥ ㎝ ㎞", "U+2205 U+2300 U+2190-2193 U+33A1 U+339C U+33A5 U+339D U+339E",
+ "# KS X 1001 Hangul syllables (2350)",
+]
+lines += ["U+%04X" % cp for cp in ks]
+# Next to this script: the checked-in tools/unicodes.txt that pyftsubset
+# reads, so the command works from any directory. newline='' keeps the LF
+# line endings the repository stores (.gitattributes: eol=lf); without it
+# Python would translate them to CRLF on Windows and the file would differ
+# from the checked-in one byte for byte.
+out = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'unicodes.txt')
+open(out, 'w', encoding='utf-8', newline='').write("\n".join(lines) + "\n")
+print("wrote", out, "with", len(ks), "syllables")
diff --git a/crates/uncad-export/fonts/tools/rename.py b/crates/uncad-export/fonts/tools/rename.py
new file mode 100644
index 0000000..f60f6e1
--- /dev/null
+++ b/crates/uncad-export/fonts/tools/rename.py
@@ -0,0 +1,20 @@
+import sys
+from fontTools.ttLib import TTFont
+src, dst, family, psname = sys.argv[1:5]
+f = TTFont(src)
+full = family + " Regular"
+for rec in f['name'].names:
+ if rec.nameID in (1, 16): rec.string = family
+ elif rec.nameID == 3: rec.string = f"{psname};uncad-subset"
+ elif rec.nameID == 4: rec.string = full
+ elif rec.nameID == 6: rec.string = psname
+ elif rec.nameID == 5: rec.string = rec.toUnicode() + "; uncad subset"
+if 'CFF ' in f:
+ cff = f['CFF '].cff
+ cff.fontNames[0] = psname
+ td = cff.topDictIndex[0]
+ td.FamilyName = family; td.FullName = full
+f.save(dst)
+g = TTFont(dst); n = g['name']
+print("renamed ->", dst, "| family:", n.getDebugName(1), "| full:", n.getDebugName(4), "| ps:", n.getDebugName(6), "| id3:", n.getDebugName(3), "| ver:", n.getDebugName(5))
+if 'CFF ' in g: print("CFF fontName:", g['CFF '].cff.fontNames[0], "FamilyName:", g['CFF '].cff.topDictIndex[0].FamilyName)
diff --git a/crates/uncad-export/fonts/tools/unicodes.txt b/crates/uncad-export/fonts/tools/unicodes.txt
new file mode 100644
index 0000000..6fd2dcc
--- /dev/null
+++ b/crates/uncad-export/fonts/tools/unicodes.txt
@@ -0,0 +1,2365 @@
+# Basic Latin
+U+0020-007E
+# Latin-1 Supplement (degree B0, plus-minus B1, sup2 B2, sup3 B3, times D7, Oslash D8, oslash F8)
+U+00A0-00FF
+# Latin Extended-A
+U+0100-017F
+# Greek and Coptic (phi, delta, omega, ...)
+U+0370-03FF
+# General Punctuation (primes, dashes, bullets)
+U+2000-206F
+# Hangul Compatibility Jamo
+U+3130-318F
+# CAD symbols: empty set (diameter), diameter sign, arrows, ㎡ ㎜ ㎥ ㎝ ㎞
+U+2205 U+2300 U+2190-2193 U+33A1 U+339C U+33A5 U+339D U+339E
+# KS X 1001 Hangul syllables (2350)
+U+AC00
+U+AC01
+U+AC04
+U+AC07
+U+AC08
+U+AC09
+U+AC0A
+U+AC10
+U+AC11
+U+AC12
+U+AC13
+U+AC14
+U+AC15
+U+AC16
+U+AC17
+U+AC19
+U+AC1A
+U+AC1B
+U+AC1C
+U+AC1D
+U+AC20
+U+AC24
+U+AC2C
+U+AC2D
+U+AC2F
+U+AC30
+U+AC31
+U+AC38
+U+AC39
+U+AC3C
+U+AC40
+U+AC4B
+U+AC4D
+U+AC54
+U+AC58
+U+AC5C
+U+AC70
+U+AC71
+U+AC74
+U+AC77
+U+AC78
+U+AC7A
+U+AC80
+U+AC81
+U+AC83
+U+AC84
+U+AC85
+U+AC86
+U+AC89
+U+AC8A
+U+AC8B
+U+AC8C
+U+AC90
+U+AC94
+U+AC9C
+U+AC9D
+U+AC9F
+U+ACA0
+U+ACA1
+U+ACA8
+U+ACA9
+U+ACAA
+U+ACAC
+U+ACAF
+U+ACB0
+U+ACB8
+U+ACB9
+U+ACBB
+U+ACBC
+U+ACBD
+U+ACC1
+U+ACC4
+U+ACC8
+U+ACCC
+U+ACD5
+U+ACD7
+U+ACE0
+U+ACE1
+U+ACE4
+U+ACE7
+U+ACE8
+U+ACEA
+U+ACEC
+U+ACEF
+U+ACF0
+U+ACF1
+U+ACF3
+U+ACF5
+U+ACF6
+U+ACFC
+U+ACFD
+U+AD00
+U+AD04
+U+AD06
+U+AD0C
+U+AD0D
+U+AD0F
+U+AD11
+U+AD18
+U+AD1C
+U+AD20
+U+AD29
+U+AD2C
+U+AD2D
+U+AD34
+U+AD35
+U+AD38
+U+AD3C
+U+AD44
+U+AD45
+U+AD47
+U+AD49
+U+AD50
+U+AD54
+U+AD58
+U+AD61
+U+AD63
+U+AD6C
+U+AD6D
+U+AD70
+U+AD73
+U+AD74
+U+AD75
+U+AD76
+U+AD7B
+U+AD7C
+U+AD7D
+U+AD7F
+U+AD81
+U+AD82
+U+AD88
+U+AD89
+U+AD8C
+U+AD90
+U+AD9C
+U+AD9D
+U+ADA4
+U+ADB7
+U+ADC0
+U+ADC1
+U+ADC4
+U+ADC8
+U+ADD0
+U+ADD1
+U+ADD3
+U+ADDC
+U+ADE0
+U+ADE4
+U+ADF8
+U+ADF9
+U+ADFC
+U+ADFF
+U+AE00
+U+AE01
+U+AE08
+U+AE09
+U+AE0B
+U+AE0D
+U+AE14
+U+AE30
+U+AE31
+U+AE34
+U+AE37
+U+AE38
+U+AE3A
+U+AE40
+U+AE41
+U+AE43
+U+AE45
+U+AE46
+U+AE4A
+U+AE4C
+U+AE4D
+U+AE4E
+U+AE50
+U+AE54
+U+AE56
+U+AE5C
+U+AE5D
+U+AE5F
+U+AE60
+U+AE61
+U+AE65
+U+AE68
+U+AE69
+U+AE6C
+U+AE70
+U+AE78
+U+AE79
+U+AE7B
+U+AE7C
+U+AE7D
+U+AE84
+U+AE85
+U+AE8C
+U+AEBC
+U+AEBD
+U+AEBE
+U+AEC0
+U+AEC4
+U+AECC
+U+AECD
+U+AECF
+U+AED0
+U+AED1
+U+AED8
+U+AED9
+U+AEDC
+U+AEE8
+U+AEEB
+U+AEED
+U+AEF4
+U+AEF8
+U+AEFC
+U+AF07
+U+AF08
+U+AF0D
+U+AF10
+U+AF2C
+U+AF2D
+U+AF30
+U+AF32
+U+AF34
+U+AF3C
+U+AF3D
+U+AF3F
+U+AF41
+U+AF42
+U+AF43
+U+AF48
+U+AF49
+U+AF50
+U+AF5C
+U+AF5D
+U+AF64
+U+AF65
+U+AF79
+U+AF80
+U+AF84
+U+AF88
+U+AF90
+U+AF91
+U+AF95
+U+AF9C
+U+AFB8
+U+AFB9
+U+AFBC
+U+AFC0
+U+AFC7
+U+AFC8
+U+AFC9
+U+AFCB
+U+AFCD
+U+AFCE
+U+AFD4
+U+AFDC
+U+AFE8
+U+AFE9
+U+AFF0
+U+AFF1
+U+AFF4
+U+AFF8
+U+B000
+U+B001
+U+B004
+U+B00C
+U+B010
+U+B014
+U+B01C
+U+B01D
+U+B028
+U+B044
+U+B045
+U+B048
+U+B04A
+U+B04C
+U+B04E
+U+B053
+U+B054
+U+B055
+U+B057
+U+B059
+U+B05D
+U+B07C
+U+B07D
+U+B080
+U+B084
+U+B08C
+U+B08D
+U+B08F
+U+B091
+U+B098
+U+B099
+U+B09A
+U+B09C
+U+B09F
+U+B0A0
+U+B0A1
+U+B0A2
+U+B0A8
+U+B0A9
+U+B0AB
+U+B0AC
+U+B0AD
+U+B0AE
+U+B0AF
+U+B0B1
+U+B0B3
+U+B0B4
+U+B0B5
+U+B0B8
+U+B0BC
+U+B0C4
+U+B0C5
+U+B0C7
+U+B0C8
+U+B0C9
+U+B0D0
+U+B0D1
+U+B0D4
+U+B0D8
+U+B0E0
+U+B0E5
+U+B108
+U+B109
+U+B10B
+U+B10C
+U+B110
+U+B112
+U+B113
+U+B118
+U+B119
+U+B11B
+U+B11C
+U+B11D
+U+B123
+U+B124
+U+B125
+U+B128
+U+B12C
+U+B134
+U+B135
+U+B137
+U+B138
+U+B139
+U+B140
+U+B141
+U+B144
+U+B148
+U+B150
+U+B151
+U+B154
+U+B155
+U+B158
+U+B15C
+U+B160
+U+B178
+U+B179
+U+B17C
+U+B180
+U+B182
+U+B188
+U+B189
+U+B18B
+U+B18D
+U+B192
+U+B193
+U+B194
+U+B198
+U+B19C
+U+B1A8
+U+B1CC
+U+B1D0
+U+B1D4
+U+B1DC
+U+B1DD
+U+B1DF
+U+B1E8
+U+B1E9
+U+B1EC
+U+B1F0
+U+B1F9
+U+B1FB
+U+B1FD
+U+B204
+U+B205
+U+B208
+U+B20B
+U+B20C
+U+B214
+U+B215
+U+B217
+U+B219
+U+B220
+U+B234
+U+B23C
+U+B258
+U+B25C
+U+B260
+U+B268
+U+B269
+U+B274
+U+B275
+U+B27C
+U+B284
+U+B285
+U+B289
+U+B290
+U+B291
+U+B294
+U+B298
+U+B299
+U+B29A
+U+B2A0
+U+B2A1
+U+B2A3
+U+B2A5
+U+B2A6
+U+B2AA
+U+B2AC
+U+B2B0
+U+B2B4
+U+B2C8
+U+B2C9
+U+B2CC
+U+B2D0
+U+B2D2
+U+B2D8
+U+B2D9
+U+B2DB
+U+B2DD
+U+B2E2
+U+B2E4
+U+B2E5
+U+B2E6
+U+B2E8
+U+B2EB
+U+B2EC
+U+B2ED
+U+B2EE
+U+B2EF
+U+B2F3
+U+B2F4
+U+B2F5
+U+B2F7
+U+B2F8
+U+B2F9
+U+B2FA
+U+B2FB
+U+B2FF
+U+B300
+U+B301
+U+B304
+U+B308
+U+B310
+U+B311
+U+B313
+U+B314
+U+B315
+U+B31C
+U+B354
+U+B355
+U+B356
+U+B358
+U+B35B
+U+B35C
+U+B35E
+U+B35F
+U+B364
+U+B365
+U+B367
+U+B369
+U+B36B
+U+B36E
+U+B370
+U+B371
+U+B374
+U+B378
+U+B380
+U+B381
+U+B383
+U+B384
+U+B385
+U+B38C
+U+B390
+U+B394
+U+B3A0
+U+B3A1
+U+B3A8
+U+B3AC
+U+B3C4
+U+B3C5
+U+B3C8
+U+B3CB
+U+B3CC
+U+B3CE
+U+B3D0
+U+B3D4
+U+B3D5
+U+B3D7
+U+B3D9
+U+B3DB
+U+B3DD
+U+B3E0
+U+B3E4
+U+B3E8
+U+B3FC
+U+B410
+U+B418
+U+B41C
+U+B420
+U+B428
+U+B429
+U+B42B
+U+B434
+U+B450
+U+B451
+U+B454
+U+B458
+U+B460
+U+B461
+U+B463
+U+B465
+U+B46C
+U+B480
+U+B488
+U+B49D
+U+B4A4
+U+B4A8
+U+B4AC
+U+B4B5
+U+B4B7
+U+B4B9
+U+B4C0
+U+B4C4
+U+B4C8
+U+B4D0
+U+B4D5
+U+B4DC
+U+B4DD
+U+B4E0
+U+B4E3
+U+B4E4
+U+B4E6
+U+B4EC
+U+B4ED
+U+B4EF
+U+B4F1
+U+B4F8
+U+B514
+U+B515
+U+B518
+U+B51B
+U+B51C
+U+B524
+U+B525
+U+B527
+U+B528
+U+B529
+U+B52A
+U+B530
+U+B531
+U+B534
+U+B538
+U+B540
+U+B541
+U+B543
+U+B544
+U+B545
+U+B54B
+U+B54C
+U+B54D
+U+B550
+U+B554
+U+B55C
+U+B55D
+U+B55F
+U+B560
+U+B561
+U+B5A0
+U+B5A1
+U+B5A4
+U+B5A8
+U+B5AA
+U+B5AB
+U+B5B0
+U+B5B1
+U+B5B3
+U+B5B4
+U+B5B5
+U+B5BB
+U+B5BC
+U+B5BD
+U+B5C0
+U+B5C4
+U+B5CC
+U+B5CD
+U+B5CF
+U+B5D0
+U+B5D1
+U+B5D8
+U+B5EC
+U+B610
+U+B611
+U+B614
+U+B618
+U+B625
+U+B62C
+U+B634
+U+B648
+U+B664
+U+B668
+U+B69C
+U+B69D
+U+B6A0
+U+B6A4
+U+B6AB
+U+B6AC
+U+B6B1
+U+B6D4
+U+B6F0
+U+B6F4
+U+B6F8
+U+B700
+U+B701
+U+B705
+U+B728
+U+B729
+U+B72C
+U+B72F
+U+B730
+U+B738
+U+B739
+U+B73B
+U+B744
+U+B748
+U+B74C
+U+B754
+U+B755
+U+B760
+U+B764
+U+B768
+U+B770
+U+B771
+U+B773
+U+B775
+U+B77C
+U+B77D
+U+B780
+U+B784
+U+B78C
+U+B78D
+U+B78F
+U+B790
+U+B791
+U+B792
+U+B796
+U+B797
+U+B798
+U+B799
+U+B79C
+U+B7A0
+U+B7A8
+U+B7A9
+U+B7AB
+U+B7AC
+U+B7AD
+U+B7B4
+U+B7B5
+U+B7B8
+U+B7C7
+U+B7C9
+U+B7EC
+U+B7ED
+U+B7F0
+U+B7F4
+U+B7FC
+U+B7FD
+U+B7FF
+U+B800
+U+B801
+U+B807
+U+B808
+U+B809
+U+B80C
+U+B810
+U+B818
+U+B819
+U+B81B
+U+B81D
+U+B824
+U+B825
+U+B828
+U+B82C
+U+B834
+U+B835
+U+B837
+U+B838
+U+B839
+U+B840
+U+B844
+U+B851
+U+B853
+U+B85C
+U+B85D
+U+B860
+U+B864
+U+B86C
+U+B86D
+U+B86F
+U+B871
+U+B878
+U+B87C
+U+B88D
+U+B8A8
+U+B8B0
+U+B8B4
+U+B8B8
+U+B8C0
+U+B8C1
+U+B8C3
+U+B8C5
+U+B8CC
+U+B8D0
+U+B8D4
+U+B8DD
+U+B8DF
+U+B8E1
+U+B8E8
+U+B8E9
+U+B8EC
+U+B8F0
+U+B8F8
+U+B8F9
+U+B8FB
+U+B8FD
+U+B904
+U+B918
+U+B920
+U+B93C
+U+B93D
+U+B940
+U+B944
+U+B94C
+U+B94F
+U+B951
+U+B958
+U+B959
+U+B95C
+U+B960
+U+B968
+U+B969
+U+B96B
+U+B96D
+U+B974
+U+B975
+U+B978
+U+B97C
+U+B984
+U+B985
+U+B987
+U+B989
+U+B98A
+U+B98D
+U+B98E
+U+B9AC
+U+B9AD
+U+B9B0
+U+B9B4
+U+B9BC
+U+B9BD
+U+B9BF
+U+B9C1
+U+B9C8
+U+B9C9
+U+B9CC
+U+B9CE
+U+B9CF
+U+B9D0
+U+B9D1
+U+B9D2
+U+B9D8
+U+B9D9
+U+B9DB
+U+B9DD
+U+B9DE
+U+B9E1
+U+B9E3
+U+B9E4
+U+B9E5
+U+B9E8
+U+B9EC
+U+B9F4
+U+B9F5
+U+B9F7
+U+B9F8
+U+B9F9
+U+B9FA
+U+BA00
+U+BA01
+U+BA08
+U+BA15
+U+BA38
+U+BA39
+U+BA3C
+U+BA40
+U+BA42
+U+BA48
+U+BA49
+U+BA4B
+U+BA4D
+U+BA4E
+U+BA53
+U+BA54
+U+BA55
+U+BA58
+U+BA5C
+U+BA64
+U+BA65
+U+BA67
+U+BA68
+U+BA69
+U+BA70
+U+BA71
+U+BA74
+U+BA78
+U+BA83
+U+BA84
+U+BA85
+U+BA87
+U+BA8C
+U+BAA8
+U+BAA9
+U+BAAB
+U+BAAC
+U+BAB0
+U+BAB2
+U+BAB8
+U+BAB9
+U+BABB
+U+BABD
+U+BAC4
+U+BAC8
+U+BAD8
+U+BAD9
+U+BAFC
+U+BB00
+U+BB04
+U+BB0D
+U+BB0F
+U+BB11
+U+BB18
+U+BB1C
+U+BB20
+U+BB29
+U+BB2B
+U+BB34
+U+BB35
+U+BB36
+U+BB38
+U+BB3B
+U+BB3C
+U+BB3D
+U+BB3E
+U+BB44
+U+BB45
+U+BB47
+U+BB49
+U+BB4D
+U+BB4F
+U+BB50
+U+BB54
+U+BB58
+U+BB61
+U+BB63
+U+BB6C
+U+BB88
+U+BB8C
+U+BB90
+U+BBA4
+U+BBA8
+U+BBAC
+U+BBB4
+U+BBB7
+U+BBC0
+U+BBC4
+U+BBC8
+U+BBD0
+U+BBD3
+U+BBF8
+U+BBF9
+U+BBFC
+U+BBFF
+U+BC00
+U+BC02
+U+BC08
+U+BC09
+U+BC0B
+U+BC0C
+U+BC0D
+U+BC0F
+U+BC11
+U+BC14
+U+BC15
+U+BC16
+U+BC17
+U+BC18
+U+BC1B
+U+BC1C
+U+BC1D
+U+BC1E
+U+BC1F
+U+BC24
+U+BC25
+U+BC27
+U+BC29
+U+BC2D
+U+BC30
+U+BC31
+U+BC34
+U+BC38
+U+BC40
+U+BC41
+U+BC43
+U+BC44
+U+BC45
+U+BC49
+U+BC4C
+U+BC4D
+U+BC50
+U+BC5D
+U+BC84
+U+BC85
+U+BC88
+U+BC8B
+U+BC8C
+U+BC8E
+U+BC94
+U+BC95
+U+BC97
+U+BC99
+U+BC9A
+U+BCA0
+U+BCA1
+U+BCA4
+U+BCA7
+U+BCA8
+U+BCB0
+U+BCB1
+U+BCB3
+U+BCB4
+U+BCB5
+U+BCBC
+U+BCBD
+U+BCC0
+U+BCC4
+U+BCCD
+U+BCCF
+U+BCD0
+U+BCD1
+U+BCD5
+U+BCD8
+U+BCDC
+U+BCF4
+U+BCF5
+U+BCF6
+U+BCF8
+U+BCFC
+U+BD04
+U+BD05
+U+BD07
+U+BD09
+U+BD10
+U+BD14
+U+BD24
+U+BD2C
+U+BD40
+U+BD48
+U+BD49
+U+BD4C
+U+BD50
+U+BD58
+U+BD59
+U+BD64
+U+BD68
+U+BD80
+U+BD81
+U+BD84
+U+BD87
+U+BD88
+U+BD89
+U+BD8A
+U+BD90
+U+BD91
+U+BD93
+U+BD95
+U+BD99
+U+BD9A
+U+BD9C
+U+BDA4
+U+BDB0
+U+BDB8
+U+BDD4
+U+BDD5
+U+BDD8
+U+BDDC
+U+BDE9
+U+BDF0
+U+BDF4
+U+BDF8
+U+BE00
+U+BE03
+U+BE05
+U+BE0C
+U+BE0D
+U+BE10
+U+BE14
+U+BE1C
+U+BE1D
+U+BE1F
+U+BE44
+U+BE45
+U+BE48
+U+BE4C
+U+BE4E
+U+BE54
+U+BE55
+U+BE57
+U+BE59
+U+BE5A
+U+BE5B
+U+BE60
+U+BE61
+U+BE64
+U+BE68
+U+BE6A
+U+BE70
+U+BE71
+U+BE73
+U+BE74
+U+BE75
+U+BE7B
+U+BE7C
+U+BE7D
+U+BE80
+U+BE84
+U+BE8C
+U+BE8D
+U+BE8F
+U+BE90
+U+BE91
+U+BE98
+U+BE99
+U+BEA8
+U+BED0
+U+BED1
+U+BED4
+U+BED7
+U+BED8
+U+BEE0
+U+BEE3
+U+BEE4
+U+BEE5
+U+BEEC
+U+BF01
+U+BF08
+U+BF09
+U+BF18
+U+BF19
+U+BF1B
+U+BF1C
+U+BF1D
+U+BF40
+U+BF41
+U+BF44
+U+BF48
+U+BF50
+U+BF51
+U+BF55
+U+BF94
+U+BFB0
+U+BFC5
+U+BFCC
+U+BFCD
+U+BFD0
+U+BFD4
+U+BFDC
+U+BFDF
+U+BFE1
+U+C03C
+U+C051
+U+C058
+U+C05C
+U+C060
+U+C068
+U+C069
+U+C090
+U+C091
+U+C094
+U+C098
+U+C0A0
+U+C0A1
+U+C0A3
+U+C0A5
+U+C0AC
+U+C0AD
+U+C0AF
+U+C0B0
+U+C0B3
+U+C0B4
+U+C0B5
+U+C0B6
+U+C0BC
+U+C0BD
+U+C0BF
+U+C0C0
+U+C0C1
+U+C0C5
+U+C0C8
+U+C0C9
+U+C0CC
+U+C0D0
+U+C0D8
+U+C0D9
+U+C0DB
+U+C0DC
+U+C0DD
+U+C0E4
+U+C0E5
+U+C0E8
+U+C0EC
+U+C0F4
+U+C0F5
+U+C0F7
+U+C0F9
+U+C100
+U+C104
+U+C108
+U+C110
+U+C115
+U+C11C
+U+C11D
+U+C11E
+U+C11F
+U+C120
+U+C123
+U+C124
+U+C126
+U+C127
+U+C12C
+U+C12D
+U+C12F
+U+C130
+U+C131
+U+C136
+U+C138
+U+C139
+U+C13C
+U+C140
+U+C148
+U+C149
+U+C14B
+U+C14C
+U+C14D
+U+C154
+U+C155
+U+C158
+U+C15C
+U+C164
+U+C165
+U+C167
+U+C168
+U+C169
+U+C170
+U+C174
+U+C178
+U+C185
+U+C18C
+U+C18D
+U+C18E
+U+C190
+U+C194
+U+C196
+U+C19C
+U+C19D
+U+C19F
+U+C1A1
+U+C1A5
+U+C1A8
+U+C1A9
+U+C1AC
+U+C1B0
+U+C1BD
+U+C1C4
+U+C1C8
+U+C1CC
+U+C1D4
+U+C1D7
+U+C1D8
+U+C1E0
+U+C1E4
+U+C1E8
+U+C1F0
+U+C1F1
+U+C1F3
+U+C1FC
+U+C1FD
+U+C200
+U+C204
+U+C20C
+U+C20D
+U+C20F
+U+C211
+U+C218
+U+C219
+U+C21C
+U+C21F
+U+C220
+U+C228
+U+C229
+U+C22B
+U+C22D
+U+C22F
+U+C231
+U+C232
+U+C234
+U+C248
+U+C250
+U+C251
+U+C254
+U+C258
+U+C260
+U+C265
+U+C26C
+U+C26D
+U+C270
+U+C274
+U+C27C
+U+C27D
+U+C27F
+U+C281
+U+C288
+U+C289
+U+C290
+U+C298
+U+C29B
+U+C29D
+U+C2A4
+U+C2A5
+U+C2A8
+U+C2AC
+U+C2AD
+U+C2B4
+U+C2B5
+U+C2B7
+U+C2B9
+U+C2DC
+U+C2DD
+U+C2E0
+U+C2E3
+U+C2E4
+U+C2EB
+U+C2EC
+U+C2ED
+U+C2EF
+U+C2F1
+U+C2F6
+U+C2F8
+U+C2F9
+U+C2FB
+U+C2FC
+U+C300
+U+C308
+U+C309
+U+C30C
+U+C30D
+U+C313
+U+C314
+U+C315
+U+C318
+U+C31C
+U+C324
+U+C325
+U+C328
+U+C329
+U+C345
+U+C368
+U+C369
+U+C36C
+U+C370
+U+C372
+U+C378
+U+C379
+U+C37C
+U+C37D
+U+C384
+U+C388
+U+C38C
+U+C3C0
+U+C3D8
+U+C3D9
+U+C3DC
+U+C3DF
+U+C3E0
+U+C3E2
+U+C3E8
+U+C3E9
+U+C3ED
+U+C3F4
+U+C3F5
+U+C3F8
+U+C408
+U+C410
+U+C424
+U+C42C
+U+C430
+U+C434
+U+C43C
+U+C43D
+U+C448
+U+C464
+U+C465
+U+C468
+U+C46C
+U+C474
+U+C475
+U+C479
+U+C480
+U+C494
+U+C49C
+U+C4B8
+U+C4BC
+U+C4E9
+U+C4F0
+U+C4F1
+U+C4F4
+U+C4F8
+U+C4FA
+U+C4FF
+U+C500
+U+C501
+U+C50C
+U+C510
+U+C514
+U+C51C
+U+C528
+U+C529
+U+C52C
+U+C530
+U+C538
+U+C539
+U+C53B
+U+C53D
+U+C544
+U+C545
+U+C548
+U+C549
+U+C54A
+U+C54C
+U+C54D
+U+C54E
+U+C553
+U+C554
+U+C555
+U+C557
+U+C558
+U+C559
+U+C55D
+U+C55E
+U+C560
+U+C561
+U+C564
+U+C568
+U+C570
+U+C571
+U+C573
+U+C574
+U+C575
+U+C57C
+U+C57D
+U+C580
+U+C584
+U+C587
+U+C58C
+U+C58D
+U+C58F
+U+C591
+U+C595
+U+C597
+U+C598
+U+C59C
+U+C5A0
+U+C5A9
+U+C5B4
+U+C5B5
+U+C5B8
+U+C5B9
+U+C5BB
+U+C5BC
+U+C5BD
+U+C5BE
+U+C5C4
+U+C5C5
+U+C5C6
+U+C5C7
+U+C5C8
+U+C5C9
+U+C5CA
+U+C5CC
+U+C5CE
+U+C5D0
+U+C5D1
+U+C5D4
+U+C5D8
+U+C5E0
+U+C5E1
+U+C5E3
+U+C5E5
+U+C5EC
+U+C5ED
+U+C5EE
+U+C5F0
+U+C5F4
+U+C5F6
+U+C5F7
+U+C5FC
+U+C5FD
+U+C5FE
+U+C5FF
+U+C600
+U+C601
+U+C605
+U+C606
+U+C607
+U+C608
+U+C60C
+U+C610
+U+C618
+U+C619
+U+C61B
+U+C61C
+U+C624
+U+C625
+U+C628
+U+C62C
+U+C62D
+U+C62E
+U+C630
+U+C633
+U+C634
+U+C635
+U+C637
+U+C639
+U+C63B
+U+C640
+U+C641
+U+C644
+U+C648
+U+C650
+U+C651
+U+C653
+U+C654
+U+C655
+U+C65C
+U+C65D
+U+C660
+U+C66C
+U+C66F
+U+C671
+U+C678
+U+C679
+U+C67C
+U+C680
+U+C688
+U+C689
+U+C68B
+U+C68D
+U+C694
+U+C695
+U+C698
+U+C69C
+U+C6A4
+U+C6A5
+U+C6A7
+U+C6A9
+U+C6B0
+U+C6B1
+U+C6B4
+U+C6B8
+U+C6B9
+U+C6BA
+U+C6C0
+U+C6C1
+U+C6C3
+U+C6C5
+U+C6CC
+U+C6CD
+U+C6D0
+U+C6D4
+U+C6DC
+U+C6DD
+U+C6E0
+U+C6E1
+U+C6E8
+U+C6E9
+U+C6EC
+U+C6F0
+U+C6F8
+U+C6F9
+U+C6FD
+U+C704
+U+C705
+U+C708
+U+C70C
+U+C714
+U+C715
+U+C717
+U+C719
+U+C720
+U+C721
+U+C724
+U+C728
+U+C730
+U+C731
+U+C733
+U+C735
+U+C737
+U+C73C
+U+C73D
+U+C740
+U+C744
+U+C74A
+U+C74C
+U+C74D
+U+C74F
+U+C751
+U+C752
+U+C753
+U+C754
+U+C755
+U+C756
+U+C757
+U+C758
+U+C75C
+U+C760
+U+C768
+U+C76B
+U+C774
+U+C775
+U+C778
+U+C77C
+U+C77D
+U+C77E
+U+C783
+U+C784
+U+C785
+U+C787
+U+C788
+U+C789
+U+C78A
+U+C78E
+U+C790
+U+C791
+U+C794
+U+C796
+U+C797
+U+C798
+U+C79A
+U+C7A0
+U+C7A1
+U+C7A3
+U+C7A4
+U+C7A5
+U+C7A6
+U+C7AC
+U+C7AD
+U+C7B0
+U+C7B4
+U+C7BC
+U+C7BD
+U+C7BF
+U+C7C0
+U+C7C1
+U+C7C8
+U+C7C9
+U+C7CC
+U+C7CE
+U+C7D0
+U+C7D8
+U+C7DD
+U+C7E4
+U+C7E8
+U+C7EC
+U+C800
+U+C801
+U+C804
+U+C808
+U+C80A
+U+C810
+U+C811
+U+C813
+U+C815
+U+C816
+U+C81C
+U+C81D
+U+C820
+U+C824
+U+C82C
+U+C82D
+U+C82F
+U+C831
+U+C838
+U+C83C
+U+C840
+U+C848
+U+C849
+U+C84C
+U+C84D
+U+C854
+U+C870
+U+C871
+U+C874
+U+C878
+U+C87A
+U+C880
+U+C881
+U+C883
+U+C885
+U+C886
+U+C887
+U+C88B
+U+C88C
+U+C88D
+U+C894
+U+C89D
+U+C89F
+U+C8A1
+U+C8A8
+U+C8BC
+U+C8BD
+U+C8C4
+U+C8C8
+U+C8CC
+U+C8D4
+U+C8D5
+U+C8D7
+U+C8D9
+U+C8E0
+U+C8E1
+U+C8E4
+U+C8F5
+U+C8FC
+U+C8FD
+U+C900
+U+C904
+U+C905
+U+C906
+U+C90C
+U+C90D
+U+C90F
+U+C911
+U+C918
+U+C92C
+U+C934
+U+C950
+U+C951
+U+C954
+U+C958
+U+C960
+U+C961
+U+C963
+U+C96C
+U+C970
+U+C974
+U+C97C
+U+C988
+U+C989
+U+C98C
+U+C990
+U+C998
+U+C999
+U+C99B
+U+C99D
+U+C9C0
+U+C9C1
+U+C9C4
+U+C9C7
+U+C9C8
+U+C9CA
+U+C9D0
+U+C9D1
+U+C9D3
+U+C9D5
+U+C9D6
+U+C9D9
+U+C9DA
+U+C9DC
+U+C9DD
+U+C9E0
+U+C9E2
+U+C9E4
+U+C9E7
+U+C9EC
+U+C9ED
+U+C9EF
+U+C9F0
+U+C9F1
+U+C9F8
+U+C9F9
+U+C9FC
+U+CA00
+U+CA08
+U+CA09
+U+CA0B
+U+CA0C
+U+CA0D
+U+CA14
+U+CA18
+U+CA29
+U+CA4C
+U+CA4D
+U+CA50
+U+CA54
+U+CA5C
+U+CA5D
+U+CA5F
+U+CA60
+U+CA61
+U+CA68
+U+CA7D
+U+CA84
+U+CA98
+U+CABC
+U+CABD
+U+CAC0
+U+CAC4
+U+CACC
+U+CACD
+U+CACF
+U+CAD1
+U+CAD3
+U+CAD8
+U+CAD9
+U+CAE0
+U+CAEC
+U+CAF4
+U+CB08
+U+CB10
+U+CB14
+U+CB18
+U+CB20
+U+CB21
+U+CB41
+U+CB48
+U+CB49
+U+CB4C
+U+CB50
+U+CB58
+U+CB59
+U+CB5D
+U+CB64
+U+CB78
+U+CB79
+U+CB9C
+U+CBB8
+U+CBD4
+U+CBE4
+U+CBE7
+U+CBE9
+U+CC0C
+U+CC0D
+U+CC10
+U+CC14
+U+CC1C
+U+CC1D
+U+CC21
+U+CC22
+U+CC27
+U+CC28
+U+CC29
+U+CC2C
+U+CC2E
+U+CC30
+U+CC38
+U+CC39
+U+CC3B
+U+CC3C
+U+CC3D
+U+CC3E
+U+CC44
+U+CC45
+U+CC48
+U+CC4C
+U+CC54
+U+CC55
+U+CC57
+U+CC58
+U+CC59
+U+CC60
+U+CC64
+U+CC66
+U+CC68
+U+CC70
+U+CC75
+U+CC98
+U+CC99
+U+CC9C
+U+CCA0
+U+CCA8
+U+CCA9
+U+CCAB
+U+CCAC
+U+CCAD
+U+CCB4
+U+CCB5
+U+CCB8
+U+CCBC
+U+CCC4
+U+CCC5
+U+CCC7
+U+CCC9
+U+CCD0
+U+CCD4
+U+CCE4
+U+CCEC
+U+CCF0
+U+CD01
+U+CD08
+U+CD09
+U+CD0C
+U+CD10
+U+CD18
+U+CD19
+U+CD1B
+U+CD1D
+U+CD24
+U+CD28
+U+CD2C
+U+CD39
+U+CD5C
+U+CD60
+U+CD64
+U+CD6C
+U+CD6D
+U+CD6F
+U+CD71
+U+CD78
+U+CD88
+U+CD94
+U+CD95
+U+CD98
+U+CD9C
+U+CDA4
+U+CDA5
+U+CDA7
+U+CDA9
+U+CDB0
+U+CDC4
+U+CDCC
+U+CDD0
+U+CDE8
+U+CDEC
+U+CDF0
+U+CDF8
+U+CDF9
+U+CDFB
+U+CDFD
+U+CE04
+U+CE08
+U+CE0C
+U+CE14
+U+CE19
+U+CE20
+U+CE21
+U+CE24
+U+CE28
+U+CE30
+U+CE31
+U+CE33
+U+CE35
+U+CE58
+U+CE59
+U+CE5C
+U+CE5F
+U+CE60
+U+CE61
+U+CE68
+U+CE69
+U+CE6B
+U+CE6D
+U+CE74
+U+CE75
+U+CE78
+U+CE7C
+U+CE84
+U+CE85
+U+CE87
+U+CE89
+U+CE90
+U+CE91
+U+CE94
+U+CE98
+U+CEA0
+U+CEA1
+U+CEA3
+U+CEA4
+U+CEA5
+U+CEAC
+U+CEAD
+U+CEC1
+U+CEE4
+U+CEE5
+U+CEE8
+U+CEEB
+U+CEEC
+U+CEF4
+U+CEF5
+U+CEF7
+U+CEF8
+U+CEF9
+U+CF00
+U+CF01
+U+CF04
+U+CF08
+U+CF10
+U+CF11
+U+CF13
+U+CF15
+U+CF1C
+U+CF20
+U+CF24
+U+CF2C
+U+CF2D
+U+CF2F
+U+CF30
+U+CF31
+U+CF38
+U+CF54
+U+CF55
+U+CF58
+U+CF5C
+U+CF64
+U+CF65
+U+CF67
+U+CF69
+U+CF70
+U+CF71
+U+CF74
+U+CF78
+U+CF80
+U+CF85
+U+CF8C
+U+CFA1
+U+CFA8
+U+CFB0
+U+CFC4
+U+CFE0
+U+CFE1
+U+CFE4
+U+CFE8
+U+CFF0
+U+CFF1
+U+CFF3
+U+CFF5
+U+CFFC
+U+D000
+U+D004
+U+D011
+U+D018
+U+D02D
+U+D034
+U+D035
+U+D038
+U+D03C
+U+D044
+U+D045
+U+D047
+U+D049
+U+D050
+U+D054
+U+D058
+U+D060
+U+D06C
+U+D06D
+U+D070
+U+D074
+U+D07C
+U+D07D
+U+D081
+U+D0A4
+U+D0A5
+U+D0A8
+U+D0AC
+U+D0B4
+U+D0B5
+U+D0B7
+U+D0B9
+U+D0C0
+U+D0C1
+U+D0C4
+U+D0C8
+U+D0C9
+U+D0D0
+U+D0D1
+U+D0D3
+U+D0D4
+U+D0D5
+U+D0DC
+U+D0DD
+U+D0E0
+U+D0E4
+U+D0EC
+U+D0ED
+U+D0EF
+U+D0F0
+U+D0F1
+U+D0F8
+U+D10D
+U+D130
+U+D131
+U+D134
+U+D138
+U+D13A
+U+D140
+U+D141
+U+D143
+U+D144
+U+D145
+U+D14C
+U+D14D
+U+D150
+U+D154
+U+D15C
+U+D15D
+U+D15F
+U+D161
+U+D168
+U+D16C
+U+D17C
+U+D184
+U+D188
+U+D1A0
+U+D1A1
+U+D1A4
+U+D1A8
+U+D1B0
+U+D1B1
+U+D1B3
+U+D1B5
+U+D1BA
+U+D1BC
+U+D1C0
+U+D1D8
+U+D1F4
+U+D1F8
+U+D207
+U+D209
+U+D210
+U+D22C
+U+D22D
+U+D230
+U+D234
+U+D23C
+U+D23D
+U+D23F
+U+D241
+U+D248
+U+D25C
+U+D264
+U+D280
+U+D281
+U+D284
+U+D288
+U+D290
+U+D291
+U+D295
+U+D29C
+U+D2A0
+U+D2A4
+U+D2AC
+U+D2B1
+U+D2B8
+U+D2B9
+U+D2BC
+U+D2BF
+U+D2C0
+U+D2C2
+U+D2C8
+U+D2C9
+U+D2CB
+U+D2D4
+U+D2D8
+U+D2DC
+U+D2E4
+U+D2E5
+U+D2F0
+U+D2F1
+U+D2F4
+U+D2F8
+U+D300
+U+D301
+U+D303
+U+D305
+U+D30C
+U+D30D
+U+D30E
+U+D310
+U+D314
+U+D316
+U+D31C
+U+D31D
+U+D31F
+U+D320
+U+D321
+U+D325
+U+D328
+U+D329
+U+D32C
+U+D330
+U+D338
+U+D339
+U+D33B
+U+D33C
+U+D33D
+U+D344
+U+D345
+U+D37C
+U+D37D
+U+D380
+U+D384
+U+D38C
+U+D38D
+U+D38F
+U+D390
+U+D391
+U+D398
+U+D399
+U+D39C
+U+D3A0
+U+D3A8
+U+D3A9
+U+D3AB
+U+D3AD
+U+D3B4
+U+D3B8
+U+D3BC
+U+D3C4
+U+D3C5
+U+D3C8
+U+D3C9
+U+D3D0
+U+D3D8
+U+D3E1
+U+D3E3
+U+D3EC
+U+D3ED
+U+D3F0
+U+D3F4
+U+D3FC
+U+D3FD
+U+D3FF
+U+D401
+U+D408
+U+D41D
+U+D440
+U+D444
+U+D45C
+U+D460
+U+D464
+U+D46D
+U+D46F
+U+D478
+U+D479
+U+D47C
+U+D47F
+U+D480
+U+D482
+U+D488
+U+D489
+U+D48B
+U+D48D
+U+D494
+U+D4A9
+U+D4CC
+U+D4D0
+U+D4D4
+U+D4DC
+U+D4DF
+U+D4E8
+U+D4EC
+U+D4F0
+U+D4F8
+U+D4FB
+U+D4FD
+U+D504
+U+D508
+U+D50C
+U+D514
+U+D515
+U+D517
+U+D53C
+U+D53D
+U+D540
+U+D544
+U+D54C
+U+D54D
+U+D54F
+U+D551
+U+D558
+U+D559
+U+D55C
+U+D560
+U+D565
+U+D568
+U+D569
+U+D56B
+U+D56D
+U+D574
+U+D575
+U+D578
+U+D57C
+U+D584
+U+D585
+U+D587
+U+D588
+U+D589
+U+D590
+U+D5A5
+U+D5C8
+U+D5C9
+U+D5CC
+U+D5D0
+U+D5D2
+U+D5D8
+U+D5D9
+U+D5DB
+U+D5DD
+U+D5E4
+U+D5E5
+U+D5E8
+U+D5EC
+U+D5F4
+U+D5F5
+U+D5F7
+U+D5F9
+U+D600
+U+D601
+U+D604
+U+D608
+U+D610
+U+D611
+U+D613
+U+D614
+U+D615
+U+D61C
+U+D620
+U+D624
+U+D62D
+U+D638
+U+D639
+U+D63C
+U+D640
+U+D645
+U+D648
+U+D649
+U+D64B
+U+D64D
+U+D651
+U+D654
+U+D655
+U+D658
+U+D65C
+U+D667
+U+D669
+U+D670
+U+D671
+U+D674
+U+D683
+U+D685
+U+D68C
+U+D68D
+U+D690
+U+D694
+U+D69D
+U+D69F
+U+D6A1
+U+D6A8
+U+D6AC
+U+D6B0
+U+D6B9
+U+D6BB
+U+D6C4
+U+D6C5
+U+D6C8
+U+D6CC
+U+D6D1
+U+D6D4
+U+D6D7
+U+D6D9
+U+D6E0
+U+D6E4
+U+D6E8
+U+D6F0
+U+D6F5
+U+D6FC
+U+D6FD
+U+D700
+U+D704
+U+D711
+U+D718
+U+D719
+U+D71C
+U+D720
+U+D728
+U+D729
+U+D72B
+U+D72D
+U+D734
+U+D735
+U+D738
+U+D73C
+U+D744
+U+D747
+U+D749
+U+D750
+U+D751
+U+D754
+U+D756
+U+D757
+U+D758
+U+D759
+U+D760
+U+D761
+U+D763
+U+D765
+U+D769
+U+D76C
+U+D770
+U+D774
+U+D77C
+U+D77D
+U+D781
+U+D788
+U+D789
+U+D78C
+U+D790
+U+D798
+U+D799
+U+D79B
+U+D79D
diff --git a/crates/uncad-export/src/dimension.rs b/crates/uncad-export/src/dimension.rs
new file mode 100644
index 0000000..d76db55
--- /dev/null
+++ b/crates/uncad-export/src/dimension.rs
@@ -0,0 +1,1040 @@
+//! Dimension values: the measurement a DIMENSION stores, the same quantity
+//! recomputed from its definition points, whether the stored one can be
+//! believed, and the text the drawing shows for it.
+//!
+//! The model carries what the file states: `measurement` (DXF 42 as
+//! written, radians for an angular kind; `None` for the 0 and -1 writers
+//! leave when they did not measure), the definition points by DXF group,
+//! the text override, the style's name, and the anonymous `*D` block the
+//! drawn dimension is cached in. Everything a reader wants on top of that is
+//! derived here, in this order of trust for the label: a text override that
+//! is only whitespace means "no text"; an override wins over the cache
+//! (`<>` in it standing for the measurement); the cache over a value this
+//! crate formats itself.
+//!
+//! The formatter is deliberately basic -- decimal, architectural and
+//! fractional units at the style's precision, `DIMRND` rounding, `DIMZIN`'s
+//! leading- and trailing-zero suppression, `DIMPOST`'s prefix and suffix,
+//! and decimal degrees for angles; the cached label is the value of record
+//! whenever the file has one.
+
+use std::collections::BTreeMap;
+
+use serde::{Deserialize, Serialize};
+use uncad_model::model::{
+ DimensionEntity, DimensionKind, Entity, OrdinateAxis, Point3D, TextOverride, ToleranceEntity,
+};
+use uncad_model::tables::{BlockRecord, DimStyleRecord, LinearUnitFormat};
+use uncad_model::Tables;
+
+use crate::text::decode_mtext;
+
+/// The header's dimension variables, as far as the file states them: what a
+/// dimension falls back on when its own DIMSTYLE says nothing (see
+/// [`EffectiveStyle::resolve`]). `None` is "not stated".
+#[derive(Debug, Clone, Default, PartialEq)]
+pub struct DimDefaults {
+ pub dimlfac: Option,
+ pub dimdec: Option,
+ pub dimlunit: Option,
+ pub dimzin: Option,
+ pub dimadec: Option,
+ pub dimrnd: Option,
+ pub dimpost: Option,
+}
+
+impl DimDefaults {
+ /// The variables `header` states.
+ pub fn from_header(header: &uncad::Header) -> DimDefaults {
+ DimDefaults {
+ dimlfac: header.dimlfac,
+ dimdec: header.dimdec,
+ dimlunit: header.dimlunit,
+ dimzin: header.dimzin,
+ dimadec: header.dimadec,
+ dimrnd: header.dimrnd,
+ dimpost: header.dimpost.clone(),
+ }
+ }
+}
+
+/// The dimension-style variables the label depends on, resolved for one
+/// dimension: its own DIMSTYLE's when the file wrote one, the header's where
+/// it did not, and the format's documented default where neither says
+/// anything (`DIMDEC` 4, `DIMLUNIT` 2 decimal, `DIMZIN` 0, `DIMADEC` 0,
+/// `DIMLFAC` 1, no rounding, no `DIMPOST`).
+#[derive(Debug, Clone, PartialEq)]
+pub struct EffectiveStyle {
+ pub dimlfac: f64,
+ /// Decimal places, at most 8 (see [`EffectiveStyle::resolve`]).
+ pub dimdec: u16,
+ /// 1 scientific, 2 decimal, 3 engineering, 4 architectural, 5
+ /// fractional, 6 Windows desktop.
+ pub dimlunit: u16,
+ pub dimzin: u16,
+ /// Decimal places of an angle, at most 8.
+ pub dimadec: u16,
+ /// Round every linear value to a multiple of this; 0 is no rounding.
+ pub dimrnd: f64,
+ /// The prefix and suffix around a linear value: `<>` stands for the
+ /// value; without one, the whole string is a suffix.
+ pub dimpost: String,
+}
+
+/// The most decimal places a label is written with. Both `DIMDEC` and
+/// `DIMADEC` hold 0..=8 per the DXF reference, but the fields are integers
+/// a corrupt record can fill with anything: `format!` would honour 65535
+/// literally, once per dimension in the drawing.
+const MAX_DECIMALS: u16 = 8;
+
+/// Whether a DIMSTYLE record carries the style's own settings, or is the
+/// husk a file leaves behind when it names a style without writing its
+/// body.
+///
+/// The distinction is what tells "the file said nothing" from "the file
+/// said zero", and nothing in the record's numbers says it directly:
+/// LibreDWG's DXF reader creates the record, presets the few fields it has
+/// defaults for (`DIMSCALE = DIMLFAC = DIMTFAC = 1`, `DIMLUNIT = 2`) and
+/// leaves every other field at zero, exactly as if the file had written 0.
+/// The tell is the sizing pair: no usable style has both a text height and
+/// an arrow size of zero, since a zero-height dimension text draws nothing.
+/// `sample_2000.dxf` is the proof -- it names the same `Standard` style
+/// that `sample_2000.dwg` writes in full (DIMDEC 4, DIMTXT 0.18,
+/// DIMASZ 0.18) and leaves all three at 0 -- and every one of the 40-odd
+/// real styles in the corpus has DIMTXT and DIMASZ above zero.
+fn carries_a_body(style: &DimStyleRecord) -> bool {
+ style.text_height.is_some_and(|h| h > 0.0) || style.arrow_size.is_some_and(|a| a > 0.0)
+}
+
+/// A decimal-place count from a file's integer field: negative is not a
+/// count (DIMADEC's -1 is "use DIMDEC"; the caller handles that), and more
+/// than [`MAX_DECIMALS`] is capped.
+fn decimals(v: i64) -> Option {
+ (v >= 0).then(|| v.min(i64::from(MAX_DECIMALS)) as u16)
+}
+
+fn unit_code(format: LinearUnitFormat) -> u16 {
+ match format {
+ LinearUnitFormat::Scientific => 1,
+ LinearUnitFormat::Decimal => 2,
+ LinearUnitFormat::Engineering => 3,
+ LinearUnitFormat::Architectural => 4,
+ LinearUnitFormat::Fractional => 5,
+ LinearUnitFormat::WindowsDesktop => 6,
+ }
+}
+
+impl EffectiveStyle {
+ /// The values that govern a dimension's label: its own DIMSTYLE's where
+ /// the file wrote one, the header's otherwise.
+ ///
+ /// A style the file wrote is taken at its word, 0 included: DIMDEC 0
+ /// (whole millimetres) and DIMADEC 0 (whole degrees) are the ordinary
+ /// metric settings, and treating them as "unset" gave a style that asks
+ /// for "50" the header's precision instead -- "50.0000" against a
+ /// picture, a cached label and a `strings.json` key all reading "50".
+ /// DIMLUNIT and DIMLFAC keep a `> 0` guard because 0 is not a value
+ /// either can hold: the unit codes run 1..=6, and a linear factor of 0
+ /// would zero every label. A style that is only the husk a file leaves
+ /// when it names a style without writing it (see `carries_a_body`)
+ /// says nothing, and the header stands in. DIMADEC -1 is AutoCAD's "as
+ /// DIMDEC".
+ pub fn resolve(style: Option<&DimStyleRecord>, header: &DimDefaults) -> EffectiveStyle {
+ let style = style.filter(|s| carries_a_body(s));
+ let dimlfac = style
+ .and_then(|s| s.length_factor)
+ .filter(|v| *v > 0.0 && v.is_finite())
+ .or(header.dimlfac.filter(|v| *v > 0.0 && v.is_finite()))
+ .unwrap_or(1.0);
+ let dimdec = match style.and_then(|s| s.decimal_places) {
+ Some(v) => decimals(i64::from(v)),
+ None => header.dimdec.map(|v| v.min(MAX_DECIMALS)),
+ }
+ .unwrap_or(4);
+ let dimlunit = style
+ .and_then(|s| s.linear_unit_format)
+ .map(unit_code)
+ .or(header.dimlunit.filter(|v| *v > 0))
+ .unwrap_or(2);
+ let dimzin = match style.and_then(|s| s.zero_suppression) {
+ Some(v) => u16::try_from(v).unwrap_or(0),
+ None => header.dimzin.unwrap_or(0),
+ };
+ let dimadec = match style.and_then(|s| s.angular_decimal_places) {
+ Some(v) if v < 0 => Some(dimdec),
+ Some(v) => decimals(i64::from(v)),
+ None => header.dimadec.map(|v| v.min(MAX_DECIMALS)),
+ }
+ .unwrap_or(0);
+ let dimrnd = style
+ .and_then(|s| s.rounding)
+ .or(header.dimrnd)
+ .filter(|v| *v > 0.0 && v.is_finite())
+ .unwrap_or(0.0);
+ let dimpost = style
+ .and_then(|s| s.post.clone())
+ .or_else(|| header.dimpost.clone())
+ .unwrap_or_default();
+ EffectiveStyle {
+ dimlfac,
+ dimdec,
+ dimlunit,
+ dimzin,
+ dimadec,
+ dimrnd,
+ dimpost,
+ }
+ }
+}
+
+/// Whether a dimension of this kind measures an angle.
+pub fn is_angular(kind: Option) -> bool {
+ matches!(
+ kind,
+ Some(DimensionKind::Angular2Line | DimensionKind::Angular3Point)
+ )
+}
+
+/// How far a stored measurement may sit from the one the definition points
+/// give before it is treated as not a measurement of this dimension at all.
+///
+/// Derived from the corpus, not chosen: across all 26 `example_*` /
+/// `sample_*` files and the nine `AutoCADSamples*.dwg`, every DIMENSION
+/// whose file wrote a real `act_measurement` agrees with
+/// [`measurement_from_points`] to better than 2.3e-7 relative (one
+/// dimension in `example_20xx` differs at all; the rest are exact). The
+/// values this rejects are off by 100 %. 1 % leaves four orders of
+/// magnitude of headroom over the worst honest disagreement.
+const MEASUREMENT_DISAGREEMENT: f64 = 0.01;
+
+/// The measurement a DIMENSION stores when it can be believed, in the unit
+/// the package reports: degrees for an angular kind, drawing units (before
+/// `DIMLFAC`) otherwise. `None` means "ask the definition points instead".
+///
+/// The model already reads the writers' "not measured" values -- 0, and
+/// -1 -- as not stated. What is left is the file's value, which is refused
+/// here when
+/// - it is not finite, or is exactly -1 (a model built another way may
+/// still carry it);
+/// - it is negative or zero and the kind's measurement cannot be (every
+/// kind but ORDINATE, which is a signed offset from a datum);
+/// - it disagrees with the definition points by more than 1 %. Magnitudes
+/// are compared, so a sign convention reconstructed differently
+/// (ORDINATE's datum axis) does not throw a good value away, and a
+/// computed value of 0 is no evidence against a stored one.
+pub fn usable_stored_measurement(
+ stored: Option,
+ kind: Option,
+ from_points: Option,
+) -> Option {
+ let stored = stored.filter(|v| v.is_finite())?;
+ if stored == -1.0 {
+ return None;
+ }
+ let signed = kind == Some(DimensionKind::Ordinate);
+ let value = if is_angular(kind) {
+ stored.to_degrees()
+ } else {
+ stored
+ };
+ if !signed && value <= 0.0 {
+ return None;
+ }
+ if let Some(computed) = from_points {
+ let (a, b) = (value.abs(), computed.abs());
+ if b != 0.0 && (a - b).abs() > MEASUREMENT_DISAGREEMENT * a.max(b) {
+ return None;
+ }
+ }
+ Some(value)
+}
+
+/// The quantity a dimension's definition points measure, with the meaning
+/// [`usable_stored_measurement`] gives a stored one: drawing units for
+/// linear kinds (before `DIMLFAC`), degrees for angular kinds, the arc
+/// length for an arc-length dimension, a signed offset for an ordinate.
+/// `None` for an unknown kind, or when a point the kind needs is missing.
+///
+/// The points are the model's, by DXF group: 13 and 14 (`extension1`,
+/// `extension2`), 15 (`radial`), 16 (`arc`) and 10 (`definition_point`).
+/// Per kind: a rotated dimension measures 13-14 along its rotation (DXF
+/// 50); an aligned one the distance 13-14; a three-point angle the sector
+/// at 15 between 13 and 14 that holds 10; a two-line angle the sector
+/// between the line 13-14 and the line 15-10 that holds 16; a radius the
+/// distance 10-15 (the centre to the curve); a diameter the chord 10-15; an
+/// ordinate the feature 13's x or y offset from the datum 10; an arc length
+/// the arc about 15 from 13 to 14 through 10.
+pub fn measurement_from_points(d: &DimensionEntity) -> Option {
+ let p = &d.points;
+ let def = d.definition_point;
+ match d.kind? {
+ DimensionKind::Rotated => {
+ let (a, b) = (p.extension1?, p.extension2?);
+ let (dx, dy) = (b.x - a.x, b.y - a.y);
+ Some((dx * d.rotation.cos() + dy * d.rotation.sin()).abs())
+ }
+ DimensionKind::Aligned => Some(distance(&p.extension1?, &p.extension2?)),
+ DimensionKind::Angular3Point => {
+ let (center, a, b) = (p.radial?, p.extension1?, p.extension2?);
+ Some(sector_degrees(
+ ¢er,
+ &[angle_of(¢er, &a), angle_of(¢er, &b)],
+ &def?,
+ ))
+ }
+ DimensionKind::Angular2Line => {
+ let (l1s, l1e, l2s, l2e) = (p.extension1?, p.extension2?, p.radial?, def?);
+ let vertex = intersection(&l1s, &l1e, &l2s, &l2e)?;
+ let a1 = (l1e.y - l1s.y).atan2(l1e.x - l1s.x);
+ let a2 = (l2e.y - l2s.y).atan2(l2e.x - l2s.x);
+ // Each line contributes two rays from the vertex; the arc point
+ // says which of the four sectors was dimensioned.
+ let pi = std::f64::consts::PI;
+ Some(sector_degrees(
+ &vertex,
+ &[a1, a1 + pi, a2, a2 + pi],
+ &p.arc?,
+ ))
+ }
+ DimensionKind::Radius => Some(distance(&def?, &p.radial?)),
+ DimensionKind::Diameter => Some(distance(&def?, &p.radial?)),
+ DimensionKind::Ordinate => {
+ let (feature, datum) = (p.extension1?, def?);
+ Some(match d.ordinate_axis? {
+ OrdinateAxis::X => feature.x - datum.x,
+ OrdinateAxis::Y => feature.y - datum.y,
+ })
+ }
+ DimensionKind::ArcLength => {
+ let (center, a, b) = (p.radial?, p.extension1?, p.extension2?);
+ let radius = distance(¢er, &a);
+ let sweep = sector_degrees(
+ ¢er,
+ &[angle_of(¢er, &a), angle_of(¢er, &b)],
+ &def?,
+ );
+ Some(radius * sweep.to_radians())
+ }
+ }
+}
+
+fn distance(a: &Point3D, b: &Point3D) -> f64 {
+ ((b.x - a.x).powi(2) + (b.y - a.y).powi(2) + (b.z - a.z).powi(2)).sqrt()
+}
+
+fn angle_of(from: &Point3D, to: &Point3D) -> f64 {
+ (to.y - from.y).atan2(to.x - from.x)
+}
+
+/// Where two lines (each given by two points) cross, in the XY plane.
+fn intersection(p1: &Point3D, p2: &Point3D, p3: &Point3D, p4: &Point3D) -> Option {
+ let (d1x, d1y) = (p2.x - p1.x, p2.y - p1.y);
+ let (d2x, d2y) = (p4.x - p3.x, p4.y - p3.y);
+ let cross = d1x * d2y - d1y * d2x;
+ if cross.abs() < 1e-12 {
+ return None;
+ }
+ let t = ((p3.x - p1.x) * d2y - (p3.y - p1.y) * d2x) / cross;
+ Some(Point3D {
+ x: p1.x + t * d1x,
+ y: p1.y + t * d1y,
+ z: p1.z,
+ })
+}
+
+/// The angular size, in degrees, of the sector between two consecutive rays
+/// (angles in radians, from `vertex`) that contains the direction of
+/// `probe` -- the arc point a dimension was drawn through, which is how
+/// AutoCAD records which of the possible sectors was measured.
+fn sector_degrees(vertex: &Point3D, rays: &[f64], probe: &Point3D) -> f64 {
+ let tau = std::f64::consts::TAU;
+ let norm = |a: f64| a.rem_euclid(tau);
+ let mut sorted: Vec = rays.iter().map(|&a| norm(a)).collect();
+ sorted.sort_by(f64::total_cmp);
+ sorted.dedup_by(|a, b| (*a - *b).abs() < 1e-12);
+ if sorted.len() < 2 {
+ return 0.0;
+ }
+ let target = norm(angle_of(vertex, probe));
+ for i in 0..sorted.len() {
+ let start = sorted[i];
+ let end = if i + 1 < sorted.len() {
+ sorted[i + 1]
+ } else {
+ sorted[0] + tau
+ };
+ let t = if target < start { target + tau } else { target };
+ if t >= start && t <= end {
+ return (end - start).to_degrees();
+ }
+ }
+ // Unreachable in exact arithmetic; the last sector wraps around.
+ (sorted[0] + tau - sorted[sorted.len() - 1]).to_degrees()
+}
+
+/// Formats a measurement the way a basic dimension style shows it: a linear
+/// value times `DIMLFAC`, rounded to `DIMRND`, in decimal (`DIMLUNIT` 2 and
+/// every code this does not handle), architectural feet-inches (4) or
+/// fractional inches (5) at `DIMDEC` precision, `DIMZIN`'s leading (bit 4)
+/// and trailing (bit 8) zeros dropped from a decimal, and `DIMPOST`
+/// around it; an angle as decimal degrees at `DIMADEC` precision with a
+/// degree sign.
+pub fn format_measurement(value: f64, angular: bool, style: &EffectiveStyle) -> String {
+ if angular {
+ return format!("{:.*}\u{00B0}", usize::from(style.dimadec), value);
+ }
+ let mut scaled = value * style.dimlfac;
+ if style.dimrnd > 0.0 {
+ scaled = (scaled / style.dimrnd).round() * style.dimrnd;
+ }
+ let text = match style.dimlunit {
+ 4 => format_architectural(scaled, style.dimdec),
+ 5 => format_fractional(scaled, style.dimdec),
+ _ => {
+ let mut text = format!("{:.*}", usize::from(style.dimdec), scaled);
+ if style.dimzin & 8 != 0 && text.contains('.') {
+ text = text.trim_end_matches('0').trim_end_matches('.').to_string();
+ }
+ if style.dimzin & 4 != 0 {
+ if let Some(rest) = text.strip_prefix("0.") {
+ text = format!(".{rest}");
+ } else if let Some(rest) = text.strip_prefix("-0.") {
+ text = format!("-.{rest}");
+ }
+ }
+ text
+ }
+ };
+ with_post(&text, &style.dimpost)
+}
+
+/// `DIMPOST` around a formatted value: `<>` in it stands for the value;
+/// without one, the whole string follows the value.
+fn with_post(value: &str, post: &str) -> String {
+ if post.is_empty() {
+ value.to_string()
+ } else if post.contains("<>") {
+ post.replacen("<>", value, 1)
+ } else {
+ format!("{value}{post}")
+ }
+}
+
+/// `7'-4 1/2"`: whole feet, whole inches, and a fraction to the nearest
+/// `1/2^DIMDEC` inch (for architectural units `DIMDEC` is that exponent).
+fn format_architectural(inches: f64, dimdec: u16) -> String {
+ let sign = if inches < 0.0 { "-" } else { "" };
+ let (whole, fraction) = split_fraction(inches.abs(), dimdec);
+ let feet = whole / 12;
+ let rest = whole % 12;
+ let mut text = format!("{sign}{feet}'-{rest}");
+ if let Some(fraction) = fraction {
+ text.push(' ');
+ text.push_str(&fraction);
+ }
+ text.push('"');
+ text
+}
+
+fn format_fractional(value: f64, dimdec: u16) -> String {
+ let sign = if value < 0.0 { "-" } else { "" };
+ let (whole, fraction) = split_fraction(value.abs(), dimdec);
+ match fraction {
+ Some(fraction) if whole == 0 => format!("{sign}{fraction}"),
+ Some(fraction) => format!("{sign}{whole} {fraction}"),
+ None => format!("{sign}{whole}"),
+ }
+}
+
+/// Rounds `value` to the nearest `1/2^dimdec` and splits it into a whole
+/// part and a reduced fraction (`None` when there is none).
+fn split_fraction(value: f64, dimdec: u16) -> (u64, Option) {
+ let denominator: u64 = 1 << dimdec.min(MAX_DECIMALS);
+ let total = (value * denominator as f64).round() as u64;
+ let whole = total / denominator;
+ let mut numerator = total % denominator;
+ if numerator == 0 {
+ return (whole, None);
+ }
+ let mut den = denominator;
+ while numerator.is_multiple_of(2) {
+ numerator /= 2;
+ den /= 2;
+ }
+ (whole, Some(format!("{numerator}/{den}")))
+}
+
+/// Where a dimension's label came from.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum DisplaySource {
+ /// Nothing to show: no override, no cached label, no measurement.
+ None,
+ /// The text override (DXF 1), with `<>` replaced by the formatted
+ /// measurement.
+ UserText,
+ /// The TEXT/MTEXT inside the cached `*D` block: what the drawing shows.
+ CachedBlock,
+ /// This crate formatted the measurement itself (basic rules only).
+ Formatted,
+ /// The override is whitespace: AutoCAD's "no text" convention.
+ Suppressed,
+}
+
+/// A dimension's label, as a reader sees it and as the file wrote it.
+#[derive(Debug, Clone, PartialEq)]
+pub struct DisplayText {
+ /// With the codes read (see [`crate::text`]).
+ pub text: String,
+ /// As the file wrote it: the override, or the cached block's strings;
+ /// empty for a label this crate formatted.
+ pub raw: String,
+ pub source: DisplaySource,
+}
+
+/// The text inside a dimension's cached block: `(raw, plain)`, the block's
+/// TEXT and MTEXT entities in order, joined by a space; `None` when it has
+/// none, or only whitespace.
+pub fn cached_label(block: &BlockRecord) -> Option<(String, String)> {
+ let mut raw = Vec::new();
+ let mut plain = Vec::new();
+ for entity in &block.entities {
+ match entity {
+ Entity::Text(t) => {
+ raw.push(t.text.clone());
+ plain.push(crate::text::decode_text(&t.text).plain);
+ }
+ Entity::MText(m) => {
+ raw.push(m.text.clone());
+ plain.push(decode_mtext(&m.text).plain);
+ }
+ _ => {}
+ }
+ }
+ let plain = plain.join(" ");
+ (!plain.trim().is_empty()).then(|| (raw.join(" "), plain))
+}
+
+/// The label of `d`, by the precedence in the module docs. `value` is the
+/// measurement to format (see [`usable_stored_measurement`] and
+/// [`measurement_from_points`]), `cached` its block's label.
+pub fn display_text(
+ d: &DimensionEntity,
+ style: &EffectiveStyle,
+ value: Option,
+ cached: Option<&(String, String)>,
+) -> DisplayText {
+ let formatted = value.map(|v| format_measurement(v, is_angular(d.kind), style));
+ match &d.text_override {
+ TextOverride::Suppressed => DisplayText {
+ text: String::new(),
+ raw: " ".to_string(),
+ source: DisplaySource::Suppressed,
+ },
+ TextOverride::Literal(s) if s.trim().is_empty() => DisplayText {
+ text: String::new(),
+ raw: s.clone(),
+ source: DisplaySource::Suppressed,
+ },
+ TextOverride::Literal(s) => DisplayText {
+ text: decode_mtext(&s.replace("<>", formatted.as_deref().unwrap_or(""))).plain,
+ raw: s.clone(),
+ source: DisplaySource::UserText,
+ },
+ TextOverride::Measured => match (cached, formatted) {
+ (Some((raw, plain)), _) => DisplayText {
+ text: plain.clone(),
+ raw: raw.clone(),
+ source: DisplaySource::CachedBlock,
+ },
+ (None, Some(text)) => DisplayText {
+ text,
+ raw: String::new(),
+ source: DisplaySource::Formatted,
+ },
+ (None, None) => DisplayText {
+ text: String::new(),
+ raw: String::new(),
+ source: DisplaySource::None,
+ },
+ },
+ }
+}
+
+/// The cached label of every block that has one, by block name -- what
+/// [`display_text`] looks a dimension's `block_name` up in.
+pub fn cached_labels(tables: &Tables) -> BTreeMap {
+ tables
+ .block_records
+ .iter()
+ .filter_map(|(name, block)| cached_label(block).map(|label| (name.clone(), label)))
+ .collect()
+}
+
+/// The height a TOLERANCE's text is drawn at: the height the entity states
+/// when positive (LibreDWG decodes one for R13/R14 files only), else its
+/// DIMSTYLE's text height, else 1 -- the renderer's rule, so the record and
+/// the picture agree.
+pub fn tolerance_text_height(t: &ToleranceEntity, tables: &Tables) -> f64 {
+ let positive = |h: f64| (h.is_finite() && h > 0.0).then_some(h);
+ t.text_height
+ .and_then(positive)
+ .or_else(|| {
+ t.style_name
+ .resolved()
+ .and_then(|name| tables.dim_styles.get(name))
+ .and_then(|style| style.text_height)
+ .and_then(positive)
+ })
+ .unwrap_or(1.0)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use uncad_model::model::{Confidence, DimensionPoints, EntityCommon, EntityId, Origin, Ref};
+ use uncad_model::Point2D;
+
+ fn p(x: f64, y: f64) -> Point3D {
+ Point3D { x, y, z: 0.0 }
+ }
+
+ fn style() -> EffectiveStyle {
+ EffectiveStyle {
+ dimlfac: 1.0,
+ dimdec: 2,
+ dimlunit: 2,
+ dimzin: 0,
+ dimadec: 0,
+ dimrnd: 0.0,
+ dimpost: String::new(),
+ }
+ }
+
+ fn dim(kind: DimensionKind, def: Option, points: DimensionPoints) -> DimensionEntity {
+ DimensionEntity {
+ common: EntityCommon {
+ id: EntityId::new(1),
+ origin: Origin::Vector,
+ confidence: Confidence::High,
+ source_handle: Ref::Resolved("1".into()),
+ layer: Ref::Resolved("0".into()),
+ color_index: 256,
+ true_color: None,
+ invisible: false,
+ },
+ block_name: Ref::Resolved("*D1".into()),
+ kind: Some(kind),
+ measurement: None,
+ text_override: TextOverride::Measured,
+ definition_point: def,
+ text_midpoint: Point2D { x: 5.0, y: 2.0 },
+ points,
+ rotation: 0.0,
+ text_rotation: 0.0,
+ style_name: Ref::Resolved("STANDARD".into()),
+ ordinate_axis: None,
+ }
+ }
+
+ fn pts(
+ e1: Option,
+ e2: Option,
+ radial: Option,
+ arc: Option,
+ ) -> DimensionPoints {
+ DimensionPoints {
+ extension1: e1,
+ extension2: e2,
+ radial,
+ arc,
+ }
+ }
+
+ #[test]
+ fn a_rotated_dimension_projects_on_its_direction_and_an_aligned_one_does_not() {
+ let mut d = dim(
+ DimensionKind::Rotated,
+ Some(p(0.0, 0.0)),
+ pts(Some(p(0.0, 0.0)), Some(p(3.0, 4.0)), None, None),
+ );
+ assert_eq!(measurement_from_points(&d), Some(3.0));
+ d.rotation = std::f64::consts::FRAC_PI_2;
+ assert!((measurement_from_points(&d).unwrap() - 4.0).abs() < 1e-12);
+ d.kind = Some(DimensionKind::Aligned);
+ assert_eq!(measurement_from_points(&d), Some(5.0));
+ // A point the kind needs is missing: nothing to compute.
+ d.points.extension2 = None;
+ assert_eq!(measurement_from_points(&d), None);
+ d.kind = None;
+ assert_eq!(measurement_from_points(&d), None);
+ }
+
+ #[test]
+ fn radius_and_diameter_are_the_chord_distances() {
+ // RADIUS: group 10 is the centre, 15 the point on the curve.
+ let radius = dim(
+ DimensionKind::Radius,
+ Some(p(0.0, 0.0)),
+ pts(None, None, Some(p(0.0, 7.0)), None),
+ );
+ assert_eq!(measurement_from_points(&radius), Some(7.0));
+ // DIAMETER: 10 and 15 are the two ends of the chord.
+ let diameter = dim(
+ DimensionKind::Diameter,
+ Some(p(-5.0, 0.0)),
+ pts(None, None, Some(p(5.0, 0.0)), None),
+ );
+ assert_eq!(measurement_from_points(&diameter), Some(10.0));
+ }
+
+ #[test]
+ fn angular_sectors_follow_the_arc_point() {
+ // Three-point: vertex 15, ends 13 and 14, the arc through 10.
+ let mut three_point = dim(
+ DimensionKind::Angular3Point,
+ Some(p(5.0, 5.0)),
+ pts(
+ Some(p(10.0, 0.0)),
+ Some(p(0.0, 10.0)),
+ Some(p(0.0, 0.0)),
+ None,
+ ),
+ );
+ let inside = measurement_from_points(&three_point).unwrap();
+ assert!((inside - 90.0).abs() < 1e-9, "{inside}");
+ three_point.definition_point = Some(p(-5.0, -5.0));
+ let reflex = measurement_from_points(&three_point).unwrap();
+ assert!((reflex - 270.0).abs() < 1e-9, "{reflex}");
+
+ // Two lines crossing at 108 degrees (a 72-degree acute pair): the
+ // first line 13-14, the second 15-10, the arc point 16.
+ let a = 108f64.to_radians();
+ let two_line = |probe: Point3D| {
+ dim(
+ DimensionKind::Angular2Line,
+ Some(p(10.0 * a.cos(), 10.0 * a.sin())),
+ pts(
+ Some(p(0.0, 0.0)),
+ Some(p(10.0, 0.0)),
+ Some(p(0.0, 0.0)),
+ Some(probe),
+ ),
+ )
+ };
+ let obtuse =
+ measurement_from_points(&two_line(p((a / 2.0).cos() * 5.0, (a / 2.0).sin() * 5.0)))
+ .unwrap();
+ assert!((obtuse - 108.0).abs() < 1e-9, "{obtuse}");
+ let acute = measurement_from_points(&two_line(p(-1.0, 3.0))).unwrap();
+ assert!((acute - 72.0).abs() < 1e-9, "{acute}");
+ }
+
+ #[test]
+ fn ordinate_and_arc_length() {
+ let mut ordinate = dim(
+ DimensionKind::Ordinate,
+ Some(p(10.0, 10.0)),
+ pts(Some(p(25.0, 40.0)), Some(p(25.0, 60.0)), None, None),
+ );
+ ordinate.ordinate_axis = Some(OrdinateAxis::X);
+ assert_eq!(measurement_from_points(&ordinate), Some(15.0));
+ ordinate.ordinate_axis = Some(OrdinateAxis::Y);
+ assert_eq!(measurement_from_points(&ordinate), Some(30.0));
+ ordinate.ordinate_axis = None;
+ assert_eq!(measurement_from_points(&ordinate), None);
+ let arc = dim(
+ DimensionKind::ArcLength,
+ Some(p(1.0, 1.0)),
+ pts(
+ Some(p(2.0, 0.0)),
+ Some(p(0.0, 2.0)),
+ Some(p(0.0, 0.0)),
+ None,
+ ),
+ );
+ let length = measurement_from_points(&arc).unwrap();
+ assert!((length - std::f64::consts::PI).abs() < 1e-9, "{length}");
+ }
+
+ #[test]
+ fn formatting_follows_the_style() {
+ let mut s = style();
+ assert_eq!(format_measurement(10.0, false, &s), "10.00");
+ s.dimlfac = 12.0;
+ assert_eq!(format_measurement(10.0, false, &s), "120.00");
+ s.dimzin = 8;
+ assert_eq!(format_measurement(10.0, false, &s), "120");
+ assert_eq!(format_measurement(10.55, false, &s), "126.6");
+ s.dimlfac = 1.0;
+ s.dimlunit = 4;
+ s.dimdec = 4;
+ assert_eq!(format_measurement(88.0, false, &s), "7'-4\"");
+ assert_eq!(format_measurement(92.375, false, &s), "7'-8 3/8\"");
+ assert_eq!(format_measurement(2.5, false, &s), "0'-2 1/2\"");
+ assert_eq!(format_measurement(12.0, false, &s), "1'-0\"");
+ s.dimlunit = 5;
+ assert_eq!(format_measurement(2.5, false, &s), "2 1/2");
+ assert_eq!(format_measurement(0.75, false, &s), "3/4");
+ assert_eq!(format_measurement(3.0, false, &s), "3");
+ s.dimadec = 1;
+ assert_eq!(format_measurement(108.0, true, &s), "108.0\u{00B0}");
+ }
+
+ #[test]
+ fn rounding_leading_zeros_and_the_post_string() {
+ let mut s = style();
+ // DIMRND 0.25: 10.1 is 10.00, 10.2 is 10.25.
+ s.dimrnd = 0.25;
+ assert_eq!(format_measurement(10.1, false, &s), "10.00");
+ assert_eq!(format_measurement(10.2, false, &s), "10.25");
+ s.dimrnd = 0.0;
+ // DIMZIN bit 4 drops a leading zero.
+ s.dimzin = 4;
+ assert_eq!(format_measurement(0.5, false, &s), ".50");
+ assert_eq!(format_measurement(-0.5, false, &s), "-.50");
+ assert_eq!(format_measurement(1.5, false, &s), "1.50");
+ s.dimzin = 12;
+ assert_eq!(format_measurement(0.5, false, &s), ".5");
+ s.dimzin = 0;
+ // DIMPOST: a suffix, or a template around `<>`. Not for angles.
+ s.dimpost = " mm".into();
+ assert_eq!(format_measurement(2.0, false, &s), "2.00 mm");
+ s.dimpost = "\u{2205}<> TYP".into();
+ assert_eq!(format_measurement(2.0, false, &s), "\u{2205}2.00 TYP");
+ assert_eq!(format_measurement(90.0, true, &s), "90\u{00B0}");
+ }
+
+ #[test]
+ fn display_precedence_suppressed_override_cache_formatted() {
+ let base = dim(
+ DimensionKind::Aligned,
+ Some(p(0.0, 0.0)),
+ pts(Some(p(0.0, 0.0)), Some(p(10.0, 0.0)), None, None),
+ );
+ let cached = ("\\A1;120".to_string(), "120".to_string());
+
+ let mut d = base.clone();
+ d.text_override = TextOverride::Suppressed;
+ let shown = display_text(&d, &style(), Some(10.0), Some(&cached));
+ assert_eq!(
+ (shown.text.as_str(), shown.source),
+ ("", DisplaySource::Suppressed)
+ );
+ d.text_override = TextOverride::Literal(" ".into());
+ let shown = display_text(&d, &style(), Some(10.0), Some(&cached));
+ assert_eq!(shown.source, DisplaySource::Suppressed);
+
+ d.text_override = TextOverride::Literal("<> TYP.".into());
+ let shown = display_text(&d, &style(), Some(10.0), Some(&cached));
+ assert_eq!(shown.text, "10.00 TYP.");
+ assert_eq!(shown.raw, "<> TYP.");
+ assert_eq!(shown.source, DisplaySource::UserText);
+
+ let shown = display_text(&base, &style(), Some(10.0), Some(&cached));
+ assert_eq!(
+ (shown.text.as_str(), shown.source),
+ ("120", DisplaySource::CachedBlock)
+ );
+ assert_eq!(shown.raw, "\\A1;120");
+
+ let shown = display_text(&base, &style(), Some(10.0), None);
+ assert_eq!(
+ (shown.text.as_str(), shown.source),
+ ("10.00", DisplaySource::Formatted)
+ );
+ let shown = display_text(&base, &style(), None, None);
+ assert_eq!(shown.source, DisplaySource::None);
+ }
+
+ #[test]
+ fn a_stored_measurement_that_cannot_be_this_dimensions_is_refused() {
+ // Expected values come from the geometry, not from the function:
+ // the ALIGNED pair (0,0)-(3,4) is 5 units apart (3-4-5), and the
+ // ORDINATE offset is 4 - 1.
+ let aligned = Some(DimensionKind::Aligned);
+ let ordinate = Some(DimensionKind::Ordinate);
+ let take = usable_stored_measurement;
+
+ assert_eq!(take(Some(0.0), aligned, Some(5.0)), None);
+ assert_eq!(take(Some(0.0), aligned, None), None);
+ assert_eq!(take(Some(-1.0), aligned, Some(5.0)), None);
+ assert_eq!(take(Some(-5.0), aligned, Some(5.0)), None);
+ assert_eq!(take(Some(f64::NAN), aligned, Some(5.0)), None);
+ assert_eq!(take(None, aligned, Some(5.0)), None);
+ // A value that measures something else entirely.
+ assert_eq!(take(Some(500.0), aligned, Some(5.0)), None);
+ // A believable one survives, disagreement inside the tolerance
+ // included (5.02 is 0.4 % off 5.0).
+ assert_eq!(take(Some(5.0), aligned, Some(5.0)), Some(5.0));
+ assert_eq!(take(Some(5.02), aligned, Some(5.0)), Some(5.02));
+ assert_eq!(take(Some(5.0), aligned, None), Some(5.0));
+
+ // An ORDINATE is a signed offset from a datum, so 0 and a negative
+ // value are both measurements it can have ...
+ assert_eq!(take(Some(0.0), ordinate, Some(0.0)), Some(0.0));
+ assert_eq!(take(Some(-3.0), ordinate, Some(3.0)), Some(-3.0));
+ assert_eq!(take(Some(3.0), ordinate, Some(-3.0)), Some(3.0));
+ // ... but not one that disagrees with the points by 100 %.
+ assert_eq!(take(Some(0.0), ordinate, Some(3.0)), None);
+
+ // Angular kinds store radians and are reported in degrees.
+ let angular = Some(DimensionKind::Angular3Point);
+ let quarter = std::f64::consts::FRAC_PI_2;
+ let got = take(Some(quarter), angular, Some(90.0)).expect("a right angle");
+ assert!((got - 90.0).abs() < 1e-9, "{got}");
+ assert_eq!(take(Some(0.0), angular, Some(90.0)), None);
+ }
+
+ fn header(dimdec: u16, dimadec: u16, dimzin: u16) -> DimDefaults {
+ DimDefaults {
+ dimdec: Some(dimdec),
+ dimadec: Some(dimadec),
+ dimzin: Some(dimzin),
+ dimlunit: Some(2),
+ dimlfac: Some(1.0),
+ ..Default::default()
+ }
+ }
+
+ fn metric() -> DimStyleRecord {
+ DimStyleRecord {
+ name: "ISO-0".into(),
+ decimal_places: Some(0),
+ angular_decimal_places: Some(0),
+ zero_suppression: Some(0),
+ linear_unit_format: Some(LinearUnitFormat::Decimal),
+ length_factor: Some(1.0),
+ // What says the file wrote this style's body at all.
+ text_height: Some(2.5),
+ arrow_size: Some(2.5),
+ ..Default::default()
+ }
+ }
+
+ #[test]
+ fn a_style_the_file_wrote_is_taken_at_its_word_zero_included() {
+ // DIMDEC 0 ("50", whole millimetres) and DIMADEC 0 are ordinary
+ // metric settings, and were read as "unset": the header's DIMDEC 4
+ // stood in and every label came out as "50.0000".
+ let header = header(4, 3, 8);
+ let resolved = EffectiveStyle::resolve(Some(&metric()), &header);
+ assert_eq!(
+ (resolved.dimdec, resolved.dimadec, resolved.dimzin),
+ (0, 0, 0)
+ );
+ assert_eq!(format_measurement(50.0, false, &resolved), "50");
+ assert_eq!(format_measurement(108.0, true, &resolved), "108\u{00B0}");
+
+ // A style the file wrote in full is still honoured when it asks for
+ // decimals.
+ let three = DimStyleRecord {
+ decimal_places: Some(3),
+ ..metric()
+ };
+ assert_eq!(
+ format_measurement(50.0, false, &EffectiveStyle::resolve(Some(&three), &header)),
+ "50.000"
+ );
+
+ // The husk a file leaves when it names a style without writing its
+ // body reads back as zeros (LibreDWG's DXF reader presets only
+ // DIMSCALE/DIMLFAC/DIMTFAC = 1 and DIMLUNIT = 2), and says nothing:
+ // the header's values stand in.
+ let husk = DimStyleRecord {
+ name: "STANDARD".into(),
+ length_factor: Some(1.0),
+ linear_unit_format: Some(LinearUnitFormat::Decimal),
+ decimal_places: Some(0),
+ angular_decimal_places: Some(0),
+ zero_suppression: Some(0),
+ text_height: Some(0.0),
+ arrow_size: Some(0.0),
+ ..Default::default()
+ };
+ let resolved = EffectiveStyle::resolve(Some(&husk), &header);
+ assert_eq!(
+ (resolved.dimdec, resolved.dimadec, resolved.dimzin),
+ (4, 3, 8)
+ );
+ assert_eq!(
+ EffectiveStyle::resolve(None, &header),
+ EffectiveStyle::resolve(Some(&husk), &header)
+ );
+
+ // DIMLUNIT and DIMLFAC keep their guard: 0 is not a value either
+ // can hold, so a style carrying one takes the header's.
+ let zeroed = DimStyleRecord {
+ linear_unit_format: None,
+ length_factor: Some(0.0),
+ ..metric()
+ };
+ let header = DimDefaults {
+ dimlunit: Some(4),
+ dimlfac: Some(12.0),
+ ..header
+ };
+ let resolved = EffectiveStyle::resolve(Some(&zeroed), &header);
+ assert_eq!((resolved.dimlunit, resolved.dimlfac), (4, 12.0));
+
+ // Nothing stated anywhere: the format's defaults.
+ let resolved = EffectiveStyle::resolve(None, &DimDefaults::default());
+ assert_eq!(
+ (
+ resolved.dimdec,
+ resolved.dimlunit,
+ resolved.dimzin,
+ resolved.dimadec,
+ resolved.dimlfac
+ ),
+ (4, 2, 0, 0, 1.0)
+ );
+ // DIMADEC -1 is "as DIMDEC".
+ let as_dimdec = DimStyleRecord {
+ decimal_places: Some(2),
+ angular_decimal_places: Some(-1),
+ ..metric()
+ };
+ assert_eq!(
+ EffectiveStyle::resolve(Some(&as_dimdec), &DimDefaults::default()).dimadec,
+ 2
+ );
+ // The post string comes from the style, else the header.
+ let posted = DimStyleRecord {
+ post: Some("<> mm".into()),
+ ..metric()
+ };
+ let header = DimDefaults {
+ dimpost: Some(" in".into()),
+ ..DimDefaults::default()
+ };
+ assert_eq!(
+ EffectiveStyle::resolve(Some(&posted), &header).dimpost,
+ "<> mm"
+ );
+ assert_eq!(EffectiveStyle::resolve(None, &header).dimpost, " in");
+ }
+
+ #[test]
+ fn a_precision_outside_the_dxf_range_does_not_build_a_65_kb_label() {
+ // A 16-bit DIMDEC of 65535 (a corrupt record) would have `format!`
+ // build a 65 KB label for every dimension in the drawing.
+ let wild = DimStyleRecord {
+ decimal_places: Some(65_535),
+ angular_decimal_places: Some(i32::MAX),
+ ..metric()
+ };
+ let resolved = EffectiveStyle::resolve(Some(&wild), &DimDefaults::default());
+ // 8 decimals is the DXF reference's maximum for both fields.
+ assert_eq!(format_measurement(1.5, false, &resolved), "1.50000000");
+ assert_eq!(
+ format_measurement(90.0, true, &resolved),
+ "90.00000000\u{00B0}"
+ );
+ let header = DimDefaults {
+ dimdec: Some(u16::MAX),
+ ..DimDefaults::default()
+ };
+ assert_eq!(EffectiveStyle::resolve(None, &header).dimdec, 8);
+ }
+}
diff --git a/crates/uncad-export/src/fonts.rs b/crates/uncad-export/src/fonts.rs
new file mode 100644
index 0000000..ffd5ef0
--- /dev/null
+++ b/crates/uncad-export/src/fonts.rs
@@ -0,0 +1,123 @@
+//! The font the package draws and measures text with: `Uncad Sans`, a
+//! Noto Sans KR subset bundled in this crate (see `fonts/README.md` for its
+//! coverage, the reason for it and how to rebuild it, and
+//! `fonts/OFL-NotoSansKR.txt` for its licence).
+//!
+//! The renderer bundles no font: it draws text with whatever [`Fonts`] it
+//! is handed, and needs to be told the face's capital height, since a CAD
+//! text height is the height of the capitals rather than the em. [`bundled`] and [`UNCAD_SANS_CAP_HEIGHT`] are
+//! those two things for the bundled face.
+
+use std::sync::{Arc, OnceLock};
+
+use iron_render_cad::Fonts;
+
+/// The bundled face, as the OpenType file it is.
+pub static UNCAD_SANS_OTF: &[u8] = include_bytes!("../fonts/UncadSans-Regular.otf");
+
+/// The family name the bundled face is registered under.
+pub const UNCAD_SANS_FAMILY: &str = "Uncad Sans";
+
+/// The bundled face's capital height as a fraction of its em: OS/2
+/// `sCapHeight` 733 over `head.unitsPerEm` 1000, read from the font's own
+/// tables (a unit test checks the value against the embedded bytes).
+///
+/// Handed to the renderer as `ToSvgOptions::cap_height`, it makes a text of
+/// CAD height `h` draw its capitals `h` tall, and it scales the renderer's
+/// 0.6-em character advance and 0.2-em descender to what the text box
+/// estimates of this crate's earlier form assumed.
+pub const UNCAD_SANS_CAP_HEIGHT: f64 = 0.733;
+
+/// [`Fonts::Custom`] holding the bundled face and nothing else: the same
+/// pixels and the same text boxes on every machine. Built once per process;
+/// the value is cheap to clone (the bytes are shared).
+pub fn bundled() -> Fonts {
+ static BUNDLED: OnceLock = OnceLock::new();
+ BUNDLED
+ .get_or_init(|| Fonts::Custom(vec![Arc::from(UNCAD_SANS_OTF)]))
+ .clone()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// One table of an OpenType font: its bytes, found through the table
+ /// directory at the start of the file (a 12-byte header, then 16-byte
+ /// records of tag, checksum, offset, length).
+ fn otf_table<'a>(font: &'a [u8], tag: &[u8; 4]) -> &'a [u8] {
+ let count = usize::from(u16::from_be_bytes([font[4], font[5]]));
+ (0..count)
+ .map(|i| &font[12 + 16 * i..12 + 16 * (i + 1)])
+ .find(|record| &record[..4] == tag)
+ .map(|record| {
+ let offset = u32::from_be_bytes(record[8..12].try_into().unwrap()) as usize;
+ let length = u32::from_be_bytes(record[12..16].try_into().unwrap()) as usize;
+ &font[offset..offset + length]
+ })
+ .unwrap_or_else(|| panic!("the font has no {} table", String::from_utf8_lossy(tag)))
+ }
+
+ #[test]
+ fn the_cap_height_is_the_one_the_font_states() {
+ // head.unitsPerEm is at byte 18; OS/2 sCapHeight (version 2+) at 88.
+ let head = otf_table(UNCAD_SANS_OTF, b"head");
+ let units_per_em = u16::from_be_bytes([head[18], head[19]]);
+ let os2 = otf_table(UNCAD_SANS_OTF, b"OS/2");
+ assert!(u16::from_be_bytes([os2[0], os2[1]]) >= 2, "OS/2 v2+");
+ let cap_height = i16::from_be_bytes([os2[88], os2[89]]);
+ assert_eq!((cap_height, units_per_em), (733, 1000));
+ assert_eq!(
+ UNCAD_SANS_CAP_HEIGHT,
+ f64::from(cap_height) / f64::from(units_per_em)
+ );
+ }
+
+ #[test]
+ fn the_face_is_registered_under_its_own_family_name() {
+ // The name table's family records (name ID 1 and 16) say "Uncad
+ // Sans", never the Noto name the OFL asks a Modified Version not to
+ // pass as: a font database keyed by family must not pick a host's
+ // Noto Sans KR over this face, or the other way round.
+ let name = otf_table(UNCAD_SANS_OTF, b"name");
+ let count = usize::from(u16::from_be_bytes([name[2], name[3]]));
+ let storage = usize::from(u16::from_be_bytes([name[4], name[5]]));
+ let mut families = Vec::new();
+ for i in 0..count {
+ let record = &name[6 + 12 * i..6 + 12 * (i + 1)];
+ let field = |at: usize| u16::from_be_bytes([record[at], record[at + 1]]);
+ let (platform, name_id, length, offset) = (field(0), field(6), field(8), field(10));
+ if !(name_id == 1 || name_id == 16) {
+ continue;
+ }
+ let bytes = &name[storage + usize::from(offset)..][..usize::from(length)];
+ // Windows records are UTF-16BE; Macintosh ones are single-byte.
+ let text = if platform == 3 {
+ let units: Vec = bytes
+ .chunks_exact(2)
+ .map(|c| u16::from_be_bytes([c[0], c[1]]))
+ .collect();
+ String::from_utf16_lossy(&units)
+ } else {
+ String::from_utf8_lossy(bytes).into_owned()
+ };
+ families.push(text);
+ }
+ assert!(!families.is_empty());
+ assert!(
+ families.iter().all(|f| f == UNCAD_SANS_FAMILY),
+ "{families:?}"
+ );
+ }
+
+ #[test]
+ fn the_bundled_fonts_are_built_once_and_share_their_bytes() {
+ let (a, b) = (bundled(), bundled());
+ assert_eq!(a, b);
+ let (Fonts::Custom(a), Fonts::Custom(b)) = (a, b) else {
+ panic!("the bundled face is a custom font");
+ };
+ assert!(Arc::ptr_eq(&a[0], &b[0]), "one copy of the bytes");
+ assert_eq!(&*a[0], UNCAD_SANS_OTF);
+ }
+}
diff --git a/crates/uncad-export/src/frame.rs b/crates/uncad-export/src/frame.rs
new file mode 100644
index 0000000..9722cc7
--- /dev/null
+++ b/crates/uncad-export/src/frame.rs
@@ -0,0 +1,546 @@
+//! Where the package's pictures stop: the crop a caller asks for, as the
+//! renderer's [`Crop`]; the padding around it; the snap of an image's size
+//! to the model's patch lattice; and the detached groups of a drawing that
+//! become frames of their own.
+//!
+//! The renderer decides the crop -- which entities a picture is framed on
+//! and which few outliers it leaves out, and says so in its `CropReport`.
+//! What is left here is what only a reader counting patches cares about:
+//!
+//! 1. **Crop mode** ([`CropMode`]): `Auto` is the renderer's guard, handed
+//! the header's `$EXTMIN/$EXTMAX` as the stated extent it may take
+//! instead; `Raw` frames everything; `Header` frames the header's
+//! extents when they are a sane rectangle; `Fixed` a caller's rectangle.
+//! 2. **Padding** ([`auto_padding`]): 2 % of the longer side, or 24 output
+//! pixels when that is more.
+//! 3. **Lattice snap** ([`snap_to_lattice`]): the pixel size is rounded up
+//! to a multiple of the patch size (28 px for Claude) and the world
+//! rectangle grows on the right and bottom so that pixels and units stay
+//! in exact proportion -- the affine the package publishes beside every
+//! image then round-trips.
+//! 4. **Frames** ([`detached_groups`]): entities within about a cell of one
+//! another are one group; a detail drawn beside the plan is another.
+
+use std::collections::BTreeMap;
+
+use iron_render_cad::Crop;
+use serde::{Deserialize, Deserializer, Serialize, Serializer};
+
+/// An axis-aligned rectangle in world (drawing) units -- or, on a sheet, in
+/// the layout's paper units.
+///
+/// JSON form: the `[x0, y0, x1, y1]` array the package documents for every
+/// box, so a rectangle reads the same in `manifest.json`, `sheets.json`,
+/// `report.json` and the records. Reading also accepts the `{min_x, min_y,
+/// max_x, max_y}` object an earlier form of the package wrote.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct Rect {
+ pub min_x: f64,
+ pub min_y: f64,
+ pub max_x: f64,
+ pub max_y: f64,
+}
+
+impl Serialize for Rect {
+ fn serialize(&self, serializer: S) -> Result {
+ [self.min_x, self.min_y, self.max_x, self.max_y].serialize(serializer)
+ }
+}
+
+impl<'de> Deserialize<'de> for Rect {
+ fn deserialize>(deserializer: D) -> Result {
+ #[derive(Deserialize)]
+ #[serde(untagged)]
+ enum Form {
+ Array([f64; 4]),
+ Object {
+ min_x: f64,
+ min_y: f64,
+ max_x: f64,
+ max_y: f64,
+ },
+ }
+ Ok(match Form::deserialize(deserializer)? {
+ Form::Array([min_x, min_y, max_x, max_y]) => Rect::new(min_x, min_y, max_x, max_y),
+ Form::Object {
+ min_x,
+ min_y,
+ max_x,
+ max_y,
+ } => Rect::new(min_x, min_y, max_x, max_y),
+ })
+ }
+}
+
+impl From for Rect {
+ fn from(r: iron_render_cad::Rect) -> Rect {
+ Rect::new(r.min_x, r.min_y, r.max_x, r.max_y)
+ }
+}
+
+impl From for iron_render_cad::Rect {
+ fn from(r: Rect) -> iron_render_cad::Rect {
+ iron_render_cad::Rect::new(r.min_x, r.min_y, r.max_x, r.max_y)
+ }
+}
+
+impl Rect {
+ pub const fn new(min_x: f64, min_y: f64, max_x: f64, max_y: f64) -> Rect {
+ Rect {
+ min_x,
+ min_y,
+ max_x,
+ max_y,
+ }
+ }
+
+ pub fn width(&self) -> f64 {
+ self.max_x - self.min_x
+ }
+
+ pub fn height(&self) -> f64 {
+ self.max_y - self.min_y
+ }
+
+ pub fn longer_side(&self) -> f64 {
+ self.width().max(self.height())
+ }
+
+ pub fn diagonal(&self) -> f64 {
+ self.width().hypot(self.height())
+ }
+
+ /// Finite, no coordinate at or beyond 1e15 (the runaway values some
+ /// headers hold), and a positive area -- the renderer's test for a
+ /// stated extent it may take.
+ pub fn is_sane(&self) -> bool {
+ [self.min_x, self.min_y, self.max_x, self.max_y]
+ .iter()
+ .all(|v| v.is_finite() && v.abs() < 1e15)
+ && self.width() > 0.0
+ && self.height() > 0.0
+ }
+
+ /// Whether the two overlap or touch.
+ pub fn intersects(&self, other: &Rect) -> bool {
+ other.min_x <= self.max_x
+ && other.max_x >= self.min_x
+ && other.min_y <= self.max_y
+ && other.max_y >= self.min_y
+ }
+
+ /// Whether `other` lies entirely inside (edges included).
+ pub fn contains(&self, other: &Rect) -> bool {
+ other.min_x >= self.min_x
+ && other.max_x <= self.max_x
+ && other.min_y >= self.min_y
+ && other.max_y <= self.max_y
+ }
+
+ pub fn union(&self, other: &Rect) -> Rect {
+ Rect {
+ min_x: self.min_x.min(other.min_x),
+ min_y: self.min_y.min(other.min_y),
+ max_x: self.max_x.max(other.max_x),
+ max_y: self.max_y.max(other.max_y),
+ }
+ }
+
+ /// Grown by `pad` on every side.
+ pub fn padded(&self, pad: f64) -> Rect {
+ Rect {
+ min_x: self.min_x - pad,
+ min_y: self.min_y - pad,
+ max_x: self.max_x + pad,
+ max_y: self.max_y + pad,
+ }
+ }
+
+ /// The bounding rectangle of several; `None` for none.
+ pub fn bounding<'a>(rects: impl IntoIterator) -> Option {
+ rects.into_iter().fold(None, |acc: Option, r| {
+ Some(acc.map_or(*r, |a| a.union(r)))
+ })
+ }
+
+ /// A rectangle of one point.
+ pub fn at(x: f64, y: f64) -> Rect {
+ Rect::new(x, y, x, y)
+ }
+}
+
+/// The canvas a package of an empty drawing gets.
+pub const EMPTY_RECT: Rect = Rect::new(0.0, 0.0, 10.0, 10.0);
+
+/// What the package's pictures show. `Auto` is the default.
+#[derive(Debug, Clone, Copy, PartialEq, Default)]
+pub enum CropMode {
+ /// Every entity but the few outliers the renderer's guard sets aside
+ /// (a block reference scaled thousands of times, a stray point a
+ /// million units out), or the header's `$EXTMIN/$EXTMAX` when those
+ /// pass the renderer's test for a stated extent and cover more.
+ #[default]
+ Auto,
+ /// Every entity, outliers included: the raw bounds.
+ Raw,
+ /// `$EXTMIN/$EXTMAX` as stored, when they are a sane rectangle; `Auto`
+ /// otherwise.
+ Header,
+ /// Exactly this world rectangle.
+ Fixed(Rect),
+}
+
+/// Which rule produced the crop, as the manifest names it.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum CropSource {
+ /// The entities' extents, outliers set aside.
+ Content,
+ /// The header's `$EXTMIN/$EXTMAX`.
+ Header,
+ /// Every entity's extent.
+ Raw,
+ /// A rectangle the caller gave.
+ Fixed,
+ /// Nothing measured: a 10 x 10 canvas at the origin ([`EMPTY_RECT`]).
+ Empty,
+}
+
+/// The header's `$EXTMIN/$EXTMAX` as a rectangle, when both are stated and
+/// they make a sane one (see [`Rect::is_sane`]); AutoCAD writes `1e20`
+/// when it never computed them.
+pub fn header_extents(header: &uncad::Header) -> Option {
+ let (min, max) = (header.extmin?, header.extmax?);
+ let rect = Rect::new(min.x, min.y, max.x, max.y);
+ rect.is_sane().then_some(rect)
+}
+
+/// A crop mode as the renderer's [`Crop`], with the header's extents (see
+/// [`header_extents`]) as the stated extent `Auto` may take.
+pub fn renderer_crop(mode: CropMode, header: Option) -> Crop {
+ let stated = header.map(iron_render_cad::Rect::from);
+ match mode {
+ CropMode::Auto => Crop::Guarded { stated },
+ CropMode::Raw => Crop::Everything,
+ CropMode::Header => match stated {
+ Some(rect) => Crop::Window(rect),
+ None => Crop::Guarded { stated: None },
+ },
+ CropMode::Fixed(rect) => Crop::Window(rect.into()),
+ }
+}
+
+/// Which rule a render under `mode` framed its picture by: `stated_taken`
+/// and `content` are the renderer's report of it.
+pub fn crop_source(
+ mode: CropMode,
+ header: Option,
+ stated_taken: bool,
+ content: Option<&Rect>,
+) -> CropSource {
+ if content.is_none() {
+ return CropSource::Empty;
+ }
+ match mode {
+ CropMode::Raw => CropSource::Raw,
+ CropMode::Fixed(_) => CropSource::Fixed,
+ CropMode::Header if header.is_some() => CropSource::Header,
+ CropMode::Header | CropMode::Auto if stated_taken => CropSource::Header,
+ CropMode::Header | CropMode::Auto => CropSource::Content,
+ }
+}
+
+/// Rule 2: the padding for `rect`, in drawing units: 2 % of its longer
+/// side, or 24 pixels at `px_per_unit` when that is more. A degenerate
+/// (zero-size) rectangle gets half a unit, or those 24 pixels when they are
+/// more -- never less, however large the scale: a scale seeded from a
+/// zero-size rectangle is itself enormous, and 24 pixels of it came to
+/// 1e-11 units, a blank window at 1e13 px/unit.
+pub fn auto_padding(rect: &Rect, px_per_unit: Option) -> f64 {
+ let l = rect.longer_side();
+ let by_pixels = px_per_unit
+ .filter(|s| s.is_finite() && *s > 0.0)
+ .map(|s| 24.0 / s)
+ .unwrap_or(0.0);
+ if l.is_nan() || l <= 0.0 {
+ return by_pixels.max(0.5);
+ }
+ (0.02 * l).max(by_pixels)
+}
+
+/// Rule 3: the pixel size of `padded` at `px_per_unit`, rounded up to a
+/// multiple of `lattice` (0 = plain rounding), and the world rectangle
+/// grown on the right and bottom so that `width / px_per_unit` and
+/// `height / px_per_unit` equal its size exactly. Returns `(rect, width_px,
+/// height_px)`; a size beyond `u32::MAX` is clamped (the renderer refuses
+/// anything past its own edge limit anyway).
+pub fn snap_to_lattice(padded: &Rect, px_per_unit: f64, lattice: u32) -> (Rect, u32, u32) {
+ let to_px = |units: f64| -> f64 {
+ let raw = units * px_per_unit;
+ let px = if lattice > 0 {
+ let l = f64::from(lattice);
+ (raw / l - 1e-9).ceil() * l
+ } else {
+ raw.round()
+ };
+ if px.is_finite() {
+ px.max(1.0).min(f64::from(u32::MAX))
+ } else {
+ f64::from(u32::MAX)
+ }
+ };
+ let w = to_px(padded.width());
+ let h = to_px(padded.height());
+ let rect = Rect {
+ min_x: padded.min_x,
+ min_y: padded.max_y - h / px_per_unit,
+ max_x: padded.min_x + w / px_per_unit,
+ max_y: padded.max_y,
+ };
+ (rect, w as u32, h as u32)
+}
+
+/// Rule 4: groups the extents that lie within about `cell` of one another
+/// (rectangles are painted onto a grid of `cell`-sized squares; entities
+/// sharing a square or neighbouring squares are one group). Groups come
+/// back largest first (then by position), each with its indices in order.
+/// The package turns detached groups into frames.
+pub fn detached_groups(extents: &[Rect], cell: f64) -> Vec> {
+ let n = extents.len();
+ if n == 0 {
+ return Vec::new();
+ }
+ let cell = if cell.is_finite() && cell > 0.0 {
+ cell
+ } else {
+ 1.0
+ };
+ let mut parent: Vec = (0..n).collect();
+ fn find(parent: &mut [usize], mut x: usize) -> usize {
+ while parent[x] != x {
+ parent[x] = parent[parent[x]];
+ x = parent[x];
+ }
+ x
+ }
+ fn union(parent: &mut [usize], a: usize, b: usize) {
+ let (ra, rb) = (find(parent, a), find(parent, b));
+ if ra != rb {
+ parent[ra.max(rb)] = ra.min(rb);
+ }
+ }
+ // The first entity to touch a square owns it; later ones join it.
+ let mut owner: BTreeMap<(i64, i64), usize> = BTreeMap::new();
+ let key = |v: f64| (v / cell).floor() as i64;
+ for (i, r) in extents.iter().enumerate() {
+ if !(r.min_x.is_finite()
+ && r.max_x.is_finite()
+ && r.min_y.is_finite()
+ && r.max_y.is_finite())
+ {
+ continue;
+ }
+ let (x0, x1, y0, y1) = (key(r.min_x), key(r.max_x), key(r.min_y), key(r.max_y));
+ // A rectangle spanning an absurd number of squares (a runaway
+ // extent that slipped through) only paints its four corners.
+ let too_many = (x1 - x0 + 1).saturating_mul(y1 - y0 + 1) > 1 << 20;
+ let mut paint = |x: i64, y: i64| match owner.get(&(x, y)) {
+ Some(&o) => union(&mut parent, i, o),
+ None => {
+ owner.insert((x, y), i);
+ }
+ };
+ if too_many {
+ for (x, y) in [(x0, y0), (x1, y0), (x0, y1), (x1, y1)] {
+ paint(x, y);
+ }
+ } else {
+ for x in x0..=x1 {
+ for y in y0..=y1 {
+ paint(x, y);
+ }
+ }
+ }
+ }
+ // Neighbouring squares join too (8-connectivity), so the effective gap
+ // is between one and two cells.
+ for (&(x, y), &i) in &owner {
+ for dx in -1..=1i64 {
+ for dy in -1..=1i64 {
+ if let Some(&j) = owner.get(&(x + dx, y + dy)) {
+ union(&mut parent, i, j);
+ }
+ }
+ }
+ }
+ let mut groups: BTreeMap> = BTreeMap::new();
+ for i in 0..n {
+ let r = find(&mut parent, i);
+ groups.entry(r).or_default().push(i);
+ }
+ let mut out: Vec> = groups.into_values().collect();
+ let rect_of = |g: &[usize]| Rect::bounding(g.iter().map(|i| &extents[*i])).expect("non-empty");
+ out.sort_by(|a, b| {
+ b.len()
+ .cmp(&a.len())
+ .then_with(|| rect_of(a).min_x.total_cmp(&rect_of(b).min_x))
+ .then_with(|| rect_of(a).min_y.total_cmp(&rect_of(b).min_y))
+ });
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn detached_groups_split_a_plan_from_its_details() {
+ // A 100 x 50 "plan" of four lines, a detail of three lines 30 units
+ // away, and a lone dot far off. With 5-unit cells the plan and the
+ // detail are separate; with 40-unit cells they merge.
+ let mut extents = vec![
+ Rect::new(0.0, 0.0, 100.0, 0.0),
+ Rect::new(100.0, 0.0, 100.0, 50.0),
+ Rect::new(0.0, 50.0, 100.0, 50.0),
+ Rect::new(0.0, 0.0, 0.0, 50.0),
+ Rect::new(130.0, 0.0, 150.0, 0.0),
+ Rect::new(150.0, 0.0, 150.0, 20.0),
+ Rect::new(130.0, 20.0, 150.0, 20.0),
+ Rect::new(500.0, 500.0, 500.0, 500.0),
+ ];
+ let groups = detached_groups(&extents, 5.0);
+ assert_eq!(groups, vec![vec![0, 1, 2, 3], vec![4, 5, 6], vec![7]]);
+ let merged = detached_groups(&extents, 40.0);
+ assert_eq!(merged.len(), 2);
+ assert_eq!(merged[0].len(), 7);
+ // The empty interior of the plan is no gap: opposite walls meet at
+ // the corners.
+ extents.truncate(4);
+ assert_eq!(detached_groups(&extents, 1.0).len(), 1);
+ assert!(detached_groups(&[], 1.0).is_empty());
+ // A runaway extent paints only its corners, and still groups.
+ let runaway = [
+ Rect::new(0.0, 0.0, 1e12, 1e12),
+ Rect::new(0.0, 0.0, 1.0, 1.0),
+ ];
+ assert_eq!(detached_groups(&runaway, 1.0).len(), 1);
+ }
+
+ #[test]
+ fn padding_and_lattice() {
+ let rect = Rect::new(0.0, 0.0, 1000.0, 500.0);
+ assert_eq!(auto_padding(&rect, None), 20.0);
+ // 24 px at 0.5 px/unit is 48 units, more than 2 %.
+ assert_eq!(auto_padding(&rect, Some(0.5)), 48.0);
+ assert_eq!(auto_padding(&rect, Some(10.0)), 20.0);
+ let point = Rect::at(3.0, 3.0);
+ assert_eq!(auto_padding(&point, None), 0.5);
+ assert_eq!(auto_padding(&point, Some(2.0)), 12.0);
+ // A scale seeded from the same zero-size rectangle is ~1e12, which
+ // would make 24 px a 2e-11-unit window: half a unit is the floor.
+ assert_eq!(auto_padding(&point, Some(1.05e12)), 0.5);
+ assert_eq!(auto_padding(&point, Some(f64::INFINITY)), 0.5);
+ assert_eq!(auto_padding(&point, Some(0.0)), 0.5, "no scale at all");
+
+ // 1040 x 540 units at 1.5 px/unit = 1560 x 810 px, snapped up to
+ // 1568 x 812; the world rectangle grows to 1045.333.. x 541.333..
+ let padded = rect.padded(20.0);
+ let (snapped, w, h) = snap_to_lattice(&padded, 1.5, 28);
+ assert_eq!((w, h), (1568, 812));
+ assert!((snapped.width() * 1.5 - 1568.0).abs() < 1e-9);
+ assert!((snapped.height() * 1.5 - 812.0).abs() < 1e-9);
+ assert_eq!((snapped.min_x, snapped.max_y), (-20.0, 520.0));
+ assert!(snapped.max_x > padded.max_x && snapped.min_y < padded.min_y);
+ // Without a lattice: plain rounding, still exact.
+ let (plain, w, h) = snap_to_lattice(&padded, 1.5, 0);
+ assert_eq!((w, h), (1560, 810));
+ assert_eq!(plain, padded);
+ // An exact multiple is not bumped up.
+ let (_, w, _) = snap_to_lattice(&Rect::new(0.0, 0.0, 28.0, 28.0), 1.0, 28);
+ assert_eq!(w, 28);
+ }
+
+ #[test]
+ fn every_crop_mode_is_a_renderer_crop_and_names_its_source() {
+ let header = Some(Rect::new(-1.0, -1.0, 11.0, 11.0));
+ let content = Rect::new(0.0, 0.0, 10.0, 10.0);
+ let stated = iron_render_cad::Rect::new(-1.0, -1.0, 11.0, 11.0);
+ assert_eq!(
+ renderer_crop(CropMode::Auto, header),
+ Crop::Guarded {
+ stated: Some(stated)
+ }
+ );
+ assert_eq!(
+ renderer_crop(CropMode::Auto, None),
+ Crop::Guarded { stated: None }
+ );
+ assert_eq!(renderer_crop(CropMode::Raw, header), Crop::Everything);
+ assert_eq!(
+ renderer_crop(CropMode::Header, header),
+ Crop::Window(stated)
+ );
+ // No usable header: `Header` is `Auto`.
+ assert_eq!(
+ renderer_crop(CropMode::Header, None),
+ Crop::Guarded { stated: None }
+ );
+ let fixed = Rect::new(0.0, 0.0, 5.0, 5.0);
+ assert_eq!(
+ renderer_crop(CropMode::Fixed(fixed), header),
+ Crop::Window(fixed.into())
+ );
+
+ let source = |mode, header, taken, content| crop_source(mode, header, taken, content);
+ assert_eq!(
+ source(CropMode::Auto, header, false, Some(&content)),
+ CropSource::Content
+ );
+ assert_eq!(
+ source(CropMode::Auto, header, true, Some(&content)),
+ CropSource::Header
+ );
+ assert_eq!(
+ source(CropMode::Header, header, false, Some(&content)),
+ CropSource::Header
+ );
+ assert_eq!(
+ source(CropMode::Header, None, false, Some(&content)),
+ CropSource::Content
+ );
+ assert_eq!(
+ source(CropMode::Raw, header, false, Some(&content)),
+ CropSource::Raw
+ );
+ assert_eq!(
+ source(CropMode::Fixed(fixed), header, false, Some(&content)),
+ CropSource::Fixed
+ );
+ assert_eq!(
+ source(CropMode::Auto, header, false, None),
+ CropSource::Empty
+ );
+ }
+
+ #[test]
+ fn a_rect_is_the_documented_array_and_reads_both_forms() {
+ let rect = Rect::new(-6.35, -6.35, 273.05, 209.55);
+ assert_eq!(
+ serde_json::to_string(&rect).unwrap(),
+ "[-6.35,-6.35,273.05,209.55]"
+ );
+ assert_eq!(
+ serde_json::from_str::("[-6.35,-6.35,273.05,209.55]").unwrap(),
+ rect
+ );
+ let object = r#"{"min_x":-6.35,"min_y":-6.35,"max_x":273.05,"max_y":209.55}"#;
+ assert_eq!(serde_json::from_str::(object).unwrap(), rect);
+ // And the renderer's rectangle converts both ways unchanged.
+ let theirs: iron_render_cad::Rect = rect.into();
+ assert_eq!(Rect::from(theirs), rect);
+ // Sanity is the renderer's test for a stated extent.
+ assert!(rect.is_sane());
+ assert!(!Rect::new(1e20, 0.0, 1e21, 1.0).is_sane());
+ assert!(!Rect::at(5.0, 5.0).is_sane());
+ }
+}
diff --git a/crates/uncad-export/src/geom.rs b/crates/uncad-export/src/geom.rs
new file mode 100644
index 0000000..0c8e01c
--- /dev/null
+++ b/crates/uncad-export/src/geom.rs
@@ -0,0 +1,630 @@
+//! The arithmetic the package's records carry and the model does not: a
+//! polyline's length and area with its bulge arcs, whether an outline
+//! crosses itself, a polygon's centroid, and the map from an entity's own
+//! plane to the world.
+//!
+//! The model states a drawing and computes nothing but a block reference's
+//! placement ([`uncad_model::Affine2`]); lengths and areas are this
+//! consumer's derivations, and every one of them is exact arithmetic on
+//! what the file states, not an estimate.
+
+use uncad_model::model::{
+ Confidence, EntityCommon, EntityId, InsertEntity, Origin, Point2D, Point3D, PolylineVertex, Ref,
+};
+use uncad_model::Affine2;
+
+/// The circular arc a bulge describes between two vertices.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct BulgeArc {
+ pub center: Point2D,
+ pub radius: f64,
+ /// Radians, of the arc's first point about the centre.
+ pub start_angle: f64,
+ /// Radians, of the arc's second point about the centre.
+ pub end_angle: f64,
+ /// The signed included angle in radians: positive counter-clockwise.
+ pub sweep: f64,
+}
+
+impl BulgeArc {
+ pub fn length(&self) -> f64 {
+ self.radius * self.sweep.abs()
+ }
+
+ /// The area between the arc and its chord (the circular segment),
+ /// always positive.
+ pub fn segment_area(&self) -> f64 {
+ let theta = self.sweep.abs();
+ self.radius * self.radius / 2.0 * (theta - theta.sin())
+ }
+}
+
+/// The arc from `from` to `to` with the given bulge -- the tangent of a
+/// quarter of its included angle, positive counter-clockwise -- or `None`
+/// for a straight segment (bulge 0 or not a number, or coincident points).
+pub fn bulge_arc(from: Point2D, to: Point2D, bulge: f64) -> Option {
+ if bulge == 0.0 || !bulge.is_finite() {
+ return None;
+ }
+ let (dx, dy) = (to.x - from.x, to.y - from.y);
+ let chord = dx.hypot(dy);
+ if chord < 1e-12 {
+ return None;
+ }
+ let sweep = 4.0 * bulge.atan();
+ let radius = chord * (1.0 + bulge * bulge) / (4.0 * bulge.abs());
+ let sagitta = chord * bulge.abs() / 2.0;
+ // The centre sits on the chord's perpendicular bisector, to the left of
+ // travel for a counter-clockwise arc, `radius - sagitta` away (negative
+ // past a semicircle, i.e. on the bulge's own side).
+ let mid = Point2D {
+ x: (from.x + to.x) / 2.0,
+ y: (from.y + to.y) / 2.0,
+ };
+ let left = Point2D {
+ x: -dy / chord,
+ y: dx / chord,
+ };
+ let offset = (radius - sagitta) * bulge.signum();
+ let center = Point2D {
+ x: mid.x + left.x * offset,
+ y: mid.y + left.y * offset,
+ };
+ Some(BulgeArc {
+ center,
+ radius,
+ start_angle: (from.y - center.y).atan2(from.x - center.x),
+ end_angle: (to.y - center.y).atan2(to.x - center.x),
+ sweep,
+ })
+}
+
+/// One segment of a polyline.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub enum Segment {
+ Line {
+ from: Point2D,
+ to: Point2D,
+ },
+ Arc {
+ from: Point2D,
+ to: Point2D,
+ bulge: f64,
+ arc: BulgeArc,
+ },
+}
+
+impl Segment {
+ pub fn length(&self) -> f64 {
+ match self {
+ Segment::Line { from, to } => (to.x - from.x).hypot(to.y - from.y),
+ Segment::Arc { arc, .. } => arc.length(),
+ }
+ }
+}
+
+/// The segments of a polyline: a vertex's bulge applies to the segment
+/// leaving it, and a closed polyline gets the segment from its last vertex
+/// back to its first. A closing vertex that repeats the first one is
+/// dropped first, so it does not add a zero-length segment.
+pub fn polyline_segments(vertices: &[PolylineVertex], closed: bool) -> Vec {
+ let vertices = without_repeated_close(vertices, closed);
+ let n = vertices.len();
+ if n < 2 {
+ return Vec::new();
+ }
+ let count = if closed { n } else { n - 1 };
+ (0..count)
+ .map(|i| {
+ let (from, to) = (vertices[i].point, vertices[(i + 1) % n].point);
+ let bulge = vertices[i].bulge;
+ match bulge_arc(from, to, bulge) {
+ Some(arc) => Segment::Arc {
+ from,
+ to,
+ bulge,
+ arc,
+ },
+ None => Segment::Line { from, to },
+ }
+ })
+ .collect()
+}
+
+fn without_repeated_close(vertices: &[PolylineVertex], closed: bool) -> &[PolylineVertex] {
+ if closed && vertices.len() > 2 {
+ let (first, last) = (vertices[0].point, vertices[vertices.len() - 1].point);
+ if (first.x - last.x).abs() < 1e-9 && (first.y - last.y).abs() < 1e-9 {
+ return &vertices[..vertices.len() - 1];
+ }
+ }
+ vertices
+}
+
+/// The polyline's length (its perimeter when closed), arcs included.
+pub fn polyline_length(vertices: &[PolylineVertex], closed: bool) -> f64 {
+ polyline_segments(vertices, closed)
+ .iter()
+ .map(Segment::length)
+ .sum()
+}
+
+/// The signed area a polyline encloses, arcs included: the shoelace area of
+/// its vertices plus each arc's circular segment, added when the arc bulges
+/// outward and subtracted when it bulges inward. Positive for a
+/// counter-clockwise outline. An open polyline is closed by a straight
+/// segment from its last vertex back to its first for this purpose: the
+/// bulge stored on the last vertex (AutoCAD keeps one there, e.g. after
+/// BREAK or TRIM) applies to no segment, exactly as in
+/// [`polyline_segments`]. Self-intersecting outlines give a value with no
+/// geometric meaning; see [`is_simple`].
+pub fn polyline_signed_area(vertices: &[PolylineVertex], closed: bool) -> f64 {
+ let mut segments = polyline_segments(vertices, closed);
+ if !closed && vertices.len() >= 2 {
+ segments.push(Segment::Line {
+ from: vertices[vertices.len() - 1].point,
+ to: vertices[0].point,
+ });
+ }
+ // Three straight segments are the fewest that can enclose anything --
+ // but two *arcs* can. A closed two-vertex polyline with bulges is
+ // exactly what AutoCAD's DONUT command writes, and two bulges of 1
+ // describe a full circle. The shoelace terms of such a shape cancel to
+ // zero, so what is left is the two circular-segment terms, which are
+ // its area. Fewer than three segments with no arc among them still
+ // encloses nothing.
+ if segments.len() < 3 && !segments.iter().any(|s| matches!(s, Segment::Arc { .. })) {
+ return 0.0;
+ }
+ let mut area = 0.0;
+ for segment in &segments {
+ match *segment {
+ Segment::Line { from, to } => area += (from.x * to.y - to.x * from.y) / 2.0,
+ Segment::Arc {
+ from,
+ to,
+ bulge,
+ arc,
+ } => {
+ area += (from.x * to.y - to.x * from.y) / 2.0;
+ // A counter-clockwise arc bulges to the right of travel,
+ // which is outward for a counter-clockwise outline and
+ // inward for a clockwise one -- so adding a signed term does
+ // the right thing for both orientations.
+ area += arc.segment_area() * bulge.signum();
+ }
+ }
+ }
+ area
+}
+
+/// The enclosed area, unsigned, when the polyline can enclose one at all:
+/// three or more vertices, or a closed pair (two arcs, AutoCAD's DONUT).
+/// `None` otherwise.
+pub fn polyline_area(vertices: &[PolylineVertex], closed: bool) -> Option {
+ let enclosing = vertices.len() >= 3 || (closed && vertices.len() == 2);
+ enclosing.then(|| polyline_signed_area(vertices, closed).abs())
+}
+
+/// Whether the straight-segment outline through `vertices` (closed) has no
+/// two non-adjacent segments crossing. Quadratic in the vertex count --
+/// callers bound it (the package tests at most 2000 vertices); arcs are
+/// treated as their chords.
+pub fn is_simple(vertices: &[Point2D]) -> bool {
+ let vertices = if vertices.len() > 2 && vertices[0] == vertices[vertices.len() - 1] {
+ &vertices[..vertices.len() - 1]
+ } else {
+ vertices
+ };
+ let n = vertices.len();
+ if n < 4 {
+ return true;
+ }
+ for i in 0..n {
+ for j in i + 1..n {
+ if j == i + 1 || (i == 0 && j == n - 1) {
+ continue;
+ }
+ let (a, b) = (vertices[i], vertices[(i + 1) % n]);
+ let (c, d) = (vertices[j], vertices[(j + 1) % n]);
+ if segments_cross(a, b, c, d) {
+ return false;
+ }
+ }
+ }
+ true
+}
+
+fn segments_cross(a: Point2D, b: Point2D, c: Point2D, d: Point2D) -> bool {
+ let orient =
+ |p: Point2D, q: Point2D, r: Point2D| (q.x - p.x) * (r.y - p.y) - (q.y - p.y) * (r.x - p.x);
+ let (o1, o2, o3, o4) = (
+ orient(a, b, c),
+ orient(a, b, d),
+ orient(c, d, a),
+ orient(c, d, b),
+ );
+ (o1 > 0.0) != (o2 > 0.0)
+ && (o3 > 0.0) != (o4 > 0.0)
+ && o1 != 0.0
+ && o2 != 0.0
+ && o3 != 0.0
+ && o4 != 0.0
+}
+
+/// Area-weighted centroid of a polygon's vertices (straight segments); the
+/// vertices' mean when they enclose no area.
+pub fn polygon_centroid(vertices: &[Point2D]) -> Point2D {
+ let n = vertices.len();
+ if n == 0 {
+ return Point2D::default();
+ }
+ let (mut cx, mut cy, mut area) = (0.0, 0.0, 0.0);
+ for i in 0..n {
+ let (a, b) = (vertices[i], vertices[(i + 1) % n]);
+ let cross = a.x * b.y - b.x * a.y;
+ cx += (a.x + b.x) * cross;
+ cy += (a.y + b.y) * cross;
+ area += cross;
+ }
+ if area.abs() < 1e-12 {
+ let sx: f64 = vertices.iter().map(|p| p.x).sum();
+ let sy: f64 = vertices.iter().map(|p| p.y).sum();
+ return Point2D {
+ x: sx / n as f64,
+ y: sy / n as f64,
+ };
+ }
+ Point2D {
+ x: cx / (3.0 * area),
+ y: cy / (3.0 * area),
+ }
+}
+
+/// Whether `p` is inside the polygon `vertices` (even-odd rule).
+pub fn point_in_polygon(p: Point2D, vertices: &[Point2D]) -> bool {
+ let n = vertices.len();
+ if n == 0 {
+ return false;
+ }
+ let mut inside = false;
+ let mut j = n - 1;
+ for i in 0..n {
+ let (a, b) = (vertices[i], vertices[j]);
+ if (a.y > p.y) != (b.y > p.y) {
+ let x = (b.x - a.x) * (p.y - a.y) / (b.y - a.y) + a.x;
+ if p.x < x {
+ inside = !inside;
+ }
+ }
+ j = i;
+ }
+ inside
+}
+
+/// The map from an entity's own plane -- the object coordinate system whose
+/// Z axis is `extrusion`, at height `elevation` in it -- to the world's (x,
+/// y): the DXF reference's arbitrary axis algorithm, seen from above. The
+/// identity for the usual normal (0, 0, 1), a mirror across the y axis for
+/// (0, 0, -1).
+///
+/// Computed by the model's own [`Affine2::from_insert`] -- which applies
+/// exactly this map to an INSERT's placement -- for an unscaled, unrotated
+/// reference at the plane's origin, so the package and every other
+/// consumer of the model agree on the algorithm to the bit. A normal that
+/// is not a direction places the entity as (0, 0, 1) would, as it does
+/// there.
+pub fn plane_to_world(extrusion: Point3D, elevation: f64) -> Affine2 {
+ let unit = InsertEntity {
+ common: EntityCommon {
+ id: EntityId::new(0),
+ origin: Origin::Derived,
+ confidence: Confidence::High,
+ source_handle: Ref::Absent,
+ layer: Ref::Absent,
+ color_index: 256,
+ true_color: None,
+ invisible: false,
+ },
+ block_name: Ref::Absent,
+ insertion_point: Point3D {
+ x: 0.0,
+ y: 0.0,
+ z: elevation,
+ },
+ scale: Point3D {
+ x: 1.0,
+ y: 1.0,
+ z: 1.0,
+ },
+ rotation: 0.0,
+ attribs: Vec::new(),
+ extrusion,
+ };
+ Affine2::from_insert(&unit)
+}
+
+/// Whether `m` keeps shapes: a rotation, a uniform scale and possibly one
+/// mirror. A circle stays a circle through it, a bulge stays a bulge (its
+/// sign turned by a mirror), and lengths scale by `sqrt(|det|)`.
+pub fn is_similarity(m: &Affine2) -> bool {
+ let la = m.a.hypot(m.b);
+ let lb = m.c.hypot(m.d);
+ let dot = m.a * m.c + m.b * m.d;
+ la > 0.0 && lb > 0.0 && dot.abs() <= 1e-9 * la * lb && (la - lb).abs() <= 1e-9 * la.max(lb)
+}
+
+/// `vertices` taken through `m`, when `m` is a similarity: the points
+/// moved, a bulge's sign turned by a mirror (an arc that ran
+/// counter-clockwise runs clockwise in a mirrored plane), the widths scaled.
+/// `None` for a map that does not keep an arc an arc.
+pub fn map_polyline(vertices: &[PolylineVertex], m: &Affine2) -> Option> {
+ if !is_similarity(m) {
+ return None;
+ }
+ let flip = if m.determinant() < 0.0 { -1.0 } else { 1.0 };
+ let scale = m.determinant().abs().sqrt();
+ Some(
+ vertices
+ .iter()
+ .map(|v| PolylineVertex {
+ point: m.apply(v.point),
+ bulge: v.bulge * flip,
+ start_width: v.start_width * scale,
+ end_width: v.end_width * scale,
+ })
+ .collect(),
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn p(x: f64, y: f64) -> Point2D {
+ Point2D { x, y }
+ }
+
+ fn v(x: f64, y: f64, bulge: f64) -> PolylineVertex {
+ PolylineVertex {
+ point: p(x, y),
+ bulge,
+ start_width: 0.0,
+ end_width: 0.0,
+ }
+ }
+
+ fn vs(points: &[(f64, f64)], bulges: &[f64]) -> Vec {
+ points
+ .iter()
+ .enumerate()
+ .map(|(i, (x, y))| v(*x, *y, bulges.get(i).copied().unwrap_or(0.0)))
+ .collect()
+ }
+
+ fn close(a: f64, b: f64) -> bool {
+ (a - b).abs() < 1e-6
+ }
+
+ #[test]
+ fn the_design_documents_worked_example() {
+ // A 100 x 50 rectangle whose right edge is a 90-degree arc.
+ let vertices = vs(
+ &[(0.0, 0.0), (100.0, 0.0), (100.0, 50.0), (0.0, 50.0)],
+ &[0.0, 0.41421356, 0.0, 0.0],
+ );
+ let segments = polyline_segments(&vertices, true);
+ assert_eq!(segments.len(), 4);
+ let Segment::Arc { arc, .. } = segments[1] else {
+ panic!("second segment is the arc");
+ };
+ assert!(
+ close(arc.center.x, 75.0) && close(arc.center.y, 25.0),
+ "{arc:?}"
+ );
+ assert!(close(arc.radius, 35.355339), "{}", arc.radius);
+ assert!(close(arc.sweep.to_degrees(), 90.0), "{}", arc.sweep);
+ assert!(close(arc.length(), 55.536037), "{}", arc.length());
+ assert!(
+ close(arc.segment_area(), 356.747702),
+ "{}",
+ arc.segment_area()
+ );
+ assert!(close(polyline_length(&vertices, true), 305.536037));
+ assert!(close(polyline_area(&vertices, true).unwrap(), 5356.747702));
+ // The same outline clockwise: same area, negative sign. Reversed,
+ // the arc is the segment leaving vertex 1 ((100,50) down to
+ // (100,0)) and must bulge to the left of travel, i.e. negatively.
+ let cw = vs(
+ &[(0.0, 50.0), (100.0, 50.0), (100.0, 0.0), (0.0, 0.0)],
+ &[0.0, -0.41421356, 0.0, 0.0],
+ );
+ assert!(close(polyline_signed_area(&cw, true), -5356.747702));
+ let points: Vec = vertices.iter().map(|v| v.point).collect();
+ assert!(is_simple(&points));
+ }
+
+ #[test]
+ fn bulges_beyond_a_semicircle_put_the_centre_on_the_bulge_side() {
+ // A 270-degree arc from (0,0) to (10,0): bulge = tan(67.5 deg).
+ let bulge = (270f64 / 4.0).to_radians().tan();
+ let arc = bulge_arc(p(0.0, 0.0), p(10.0, 0.0), bulge).unwrap();
+ assert!(close(arc.sweep.to_degrees(), 270.0));
+ // Centre below the chord (the arc bulges to the right of travel,
+ // i.e. downward, and past a semicircle the centre is on that side):
+ // (5, -5), radius 5 sqrt(2).
+ assert!(
+ close(arc.center.x, 5.0) && close(arc.center.y, -5.0),
+ "{arc:?}"
+ );
+ assert!(close(arc.radius, 5.0 * 2f64.sqrt()), "{}", arc.radius);
+ }
+
+ #[test]
+ fn open_polylines_and_degenerate_input() {
+ let vertices = vs(&[(0.0, 0.0), (3.0, 4.0)], &[]);
+ assert!(close(polyline_length(&vertices, false), 5.0));
+ assert_eq!(polyline_segments(&vertices, true).len(), 2);
+ assert_eq!(polyline_segments(&vs(&[(1.0, 1.0)], &[]), true).len(), 0);
+ assert_eq!(polyline_area(&vertices, false), None, "two open vertices");
+ assert!(bulge_arc(p(0.0, 0.0), p(0.0, 0.0), 1.0).is_none());
+ assert!(bulge_arc(p(0.0, 0.0), p(1.0, 0.0), f64::NAN).is_none());
+ // A repeated closing vertex does not add a zero-length segment.
+ let repeated = vs(&[(0.0, 0.0), (4.0, 0.0), (4.0, 3.0), (0.0, 0.0)], &[]);
+ assert_eq!(polyline_segments(&repeated, true).len(), 3);
+ assert!(close(polyline_area(&repeated, true).unwrap(), 6.0));
+ assert!(close(polyline_length(&repeated, true), 12.0));
+ }
+
+ #[test]
+ fn an_open_polylines_last_bulge_does_not_bend_the_closing_segment() {
+ // The right triangle (0,0) -> (10,0) -> (10,10), open, with a bulge
+ // left on its last vertex. Closed by a straight segment the area is
+ // 10 * 10 / 2 = 50, and the last bulge applies to nothing. Bent, the
+ // closing chord (10,10) -> (0,0) of length 10 sqrt(2) would carry a
+ // semicircle of radius 5 sqrt(2), adding or taking pi * 50 / 2 =
+ // 78.539816.
+ for bulge in [1.0, -1.0, 669.19] {
+ let vertices = vs(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)], &[0.0, 0.0, bulge]);
+ assert!(
+ close(polyline_signed_area(&vertices, false), 50.0),
+ "bulge {bulge}: {}",
+ polyline_signed_area(&vertices, false)
+ );
+ // Length is unaffected either way: two straight sides.
+ assert!(close(polyline_length(&vertices, false), 20.0));
+ }
+ // Flagged closed, the same bulge is the real closing segment.
+ let vertices = vs(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)], &[0.0, 0.0, 1.0]);
+ assert!(close(
+ polyline_signed_area(&vertices, true),
+ 50.0 + std::f64::consts::PI * 50.0 / 2.0
+ ));
+ // An open polyline drawn back to its first vertex: the bulge on the
+ // vertex before the repeat is a real segment, the repeat's own is
+ // not. The third segment (10,10) -> (0,0) with bulge -1 is a
+ // semicircle inside the triangle, so 50 - 78.539816; the 5.0 on the
+ // repeated vertex must count for nothing.
+ let back_home = vs(
+ &[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 0.0)],
+ &[0.0, 0.0, -1.0, 5.0],
+ );
+ assert!(close(
+ polyline_signed_area(&back_home, false),
+ 50.0 - std::f64::consts::PI * 50.0 / 2.0
+ ));
+ }
+
+ #[test]
+ fn self_intersection_is_detected() {
+ let bowtie = [p(0.0, 0.0), p(10.0, 10.0), p(10.0, 0.0), p(0.0, 10.0)];
+ assert!(!is_simple(&bowtie));
+ let square = [p(0.0, 0.0), p(10.0, 0.0), p(10.0, 10.0), p(0.0, 10.0)];
+ assert!(is_simple(&square));
+ // The same square with its closing vertex repeated.
+ let repeated = [
+ p(0.0, 0.0),
+ p(10.0, 0.0),
+ p(10.0, 10.0),
+ p(0.0, 10.0),
+ p(0.0, 0.0),
+ ];
+ assert!(is_simple(&repeated));
+ }
+
+ #[test]
+ fn a_closed_two_vertex_bulged_polyline_encloses_its_real_area() {
+ // AutoCAD's DONUT: two vertices 100 apart with bulges of 1 each is
+ // a circle of radius 50, so the area is pi * 50^2 = 7853.981634 and
+ // the perimeter is 2 * pi * 50 = 314.159265. Both numbers come from
+ // the circle, not from this crate.
+ let donut = vs(&[(0.0, 0.0), (100.0, 0.0)], &[1.0, 1.0]);
+ let area = polyline_area(&donut, true).unwrap();
+ assert!(close(area, std::f64::consts::PI * 2500.0), "{area}");
+ assert!(close(
+ polyline_length(&donut, true),
+ std::f64::consts::TAU * 50.0
+ ));
+ // Orientation still reads: two negative bulges trace the same
+ // circle clockwise.
+ assert!(polyline_signed_area(&donut, true) > 0.0);
+ let clockwise = vs(&[(0.0, 0.0), (100.0, 0.0)], &[-1.0, -1.0]);
+ assert!(polyline_signed_area(&clockwise, true) < 0.0);
+ // Two *straight* segments still enclose nothing -- there and back
+ // along the same line.
+ let straight = vs(&[(0.0, 0.0), (100.0, 0.0)], &[0.0, 0.0]);
+ assert!(close(polyline_area(&straight, true).unwrap(), 0.0));
+ // One bulge of 1, one of 0: a semicircle closed by its diameter,
+ // pi * 50^2 / 2.
+ let half = vs(&[(0.0, 0.0), (100.0, 0.0)], &[1.0, 0.0]);
+ assert!(close(
+ polyline_area(&half, true).unwrap(),
+ std::f64::consts::PI * 1250.0
+ ));
+ }
+
+ #[test]
+ fn centroids_and_containment() {
+ let square = [p(0.0, 0.0), p(4.0, 0.0), p(4.0, 2.0), p(0.0, 2.0)];
+ let c = polygon_centroid(&square);
+ assert!(close(c.x, 2.0) && close(c.y, 1.0), "{c:?}");
+ assert!(point_in_polygon(p(1.0, 1.0), &square));
+ assert!(!point_in_polygon(p(5.0, 1.0), &square));
+ // No area: the mean of the vertices.
+ let line = [p(0.0, 0.0), p(10.0, 0.0)];
+ let c = polygon_centroid(&line);
+ assert!(close(c.x, 5.0) && close(c.y, 0.0), "{c:?}");
+ }
+
+ #[test]
+ fn a_mirrored_plane_turns_x_and_the_bulges_sign() {
+ // (0, 0, -1): the arbitrary axis algorithm takes the OCS x axis to
+ // the world's -x (Wy x N) and leaves y alone.
+ let mirror = plane_to_world(
+ Point3D {
+ x: 0.0,
+ y: 0.0,
+ z: -1.0,
+ },
+ 0.0,
+ );
+ let q = mirror.apply(p(3.0, 4.0));
+ assert!(close(q.x, -3.0) && close(q.y, 4.0), "{q:?}");
+ assert!(is_similarity(&mirror) && mirror.determinant() < 0.0);
+ // A counter-clockwise half circle in the mirrored plane runs
+ // clockwise in the world: same length and area, orientation turned.
+ let ocs = vs(&[(0.0, 0.0), (10.0, 0.0)], &[1.0, 1.0]);
+ let world = map_polyline(&ocs, &mirror).unwrap();
+ assert!(world.iter().all(|v| v.bulge == -1.0));
+ assert!(close(
+ polyline_length(&world, true),
+ polyline_length(&ocs, true)
+ ));
+ assert!(close(
+ polyline_signed_area(&world, true),
+ -polyline_signed_area(&ocs, true)
+ ));
+ // The usual normal is the identity, whatever the elevation.
+ let flat = plane_to_world(
+ Point3D {
+ x: 0.0,
+ y: 0.0,
+ z: 1.0,
+ },
+ 7.0,
+ );
+ assert_eq!(flat, Affine2::IDENTITY);
+ // A tilted plane seen from above is no similarity: an arc in it is
+ // an ellipse in the plan.
+ let tilted = plane_to_world(
+ Point3D {
+ x: 1.0,
+ y: 0.0,
+ z: 1.0,
+ },
+ 0.0,
+ );
+ assert!(!is_similarity(&tilted));
+ assert!(map_polyline(&ocs, &tilted).is_none());
+ }
+}
diff --git a/crates/uncad-export/src/lib.rs b/crates/uncad-export/src/lib.rs
new file mode 100644
index 0000000..6adc81f
--- /dev/null
+++ b/crates/uncad-export/src/lib.rs
@@ -0,0 +1,91 @@
+//! A DWG/DXF drawing as a package an LLM or a vision model can read.
+//!
+//! [`uncad`] parses a drawing into the neutral [`uncad_model`] entity model
+//! and draws nothing; `iron-render-cad` draws that model and knows nothing
+//! of who looks at the picture. This crate is the consumer between them that
+//! writes for one kind of reader: images sized to a model's patch budget,
+//! and JSON records with the exact numbers a picture cannot give --
+//! lengths, areas, dimension values, texts -- each pointing at the pixels it
+//! is drawn in.
+//!
+//! [`export_package`] writes the package of a parsed drawing (with its
+//! [`uncad::Header`], which the model does not carry), [`export_file`] of a
+//! file:
+//!
+//! ```text
+//! dir/
+//! README.txt reading order
+//! manifest.json source, units, profile, crop, overview, frames, frames_dropped,
+//! sheets, legibility, capabilities, counts, warnings, files,
+//! shard_index, legend, guidance
+//! drawing.json header, units, layers with their state, the block definitions, counts
+//! overview.png the whole crop, fitted to the profile (Claude: <= 1568 px edge,
+//! <= 1568 patches)
+//! frames/fN/tiles/z{z}/r{rr}_c{cc}.png + .json tiles (Claude: 1092 px, 224 px
+//! overlap) and their sidecars
+//! frames/fN/overview.png one per frame when the drawing splits into several
+//! tiles.json every tile of every level and frame: written (with its bytes and
+//! sha256) or empty (with a reason)
+//! sheets.json the paper layouts: sheet rectangle and its source, plot settings,
+//! viewports, the model-to-paper map of each composited one
+//! sheets//overview.png each layout, the model composited through its
+//! viewports
+//! texts.json TEXT/MTEXT/ATTRIB/TOLERANCE, block contents included, with the
+//! world box of their glyph outlines and the tiles they are on; the
+//! paper layouts' own texts with `space: "paper"`, their sheet and
+//! that sheet's pixel box
+//! dimensions.json measured value and where it came from, display string, points
+//! geometry.json every other visible entity: key points, length, area, bbox
+//! regions.json closed polylines: area, perimeter, centroid, the texts inside
+//! blocks.json the INSERT instances with their attributes
+//! strings.json NFKC-normalised string -> record ids
+//! report.json excluded and hidden entities with reasons, unsupported types,
+//! the renderer's robustness limits
+//! drawing.svg with `svg: true`; entities.json with `full: true`
+//! ```
+//!
+//! Every JSON file carries `"$schema": "uncad-package/1"` and a `units`
+//! block; record files above `shard_kb` are split into `name.NNN.json` and
+//! listed in the manifest's `shard_index`. The package is the same bytes for
+//! the same input and options, `report.json` included. Re-exporting into a
+//! directory first clears what the previous `manifest.json` listed, and
+//! leaves anything else there alone.
+//!
+//! The drawing is walked once (the renderer's `Scene`), and every image is
+//! a window of that walk: the overview, one per frame (a detail drawn
+//! beside the plan is a frame of its own), and a pyramid of overlapping
+//! tiles per frame, 2x per level, as deep as the frame's dominant text needs
+//! to reach the target pixel height and the tile budget allows. A tile is
+//! drawn from the parts that reach it, on as many threads as there are.
+//! Each paper layout is walked once more, as its sheet (the renderer's
+//! `Scene::layout`), and drawn whole.
+//!
+//! A text record's box is measured from the glyph outlines of the bundled
+//! font ([`fonts`]) as the renderer laid them out, and those boxes widen the
+//! drawn extents before the frames are grouped and the tiles culled; a
+//! record's id is the model's reference ID (a path of them for a text
+//! inside a block), and the file's handle is beside it. What the package
+//! derives -- a text's readable string ([`text`]), a dimension's value, the
+//! trust in its stored measurement and its label ([`dimension`]), lengths,
+//! areas and outlines ([`geom`]), the frame of each picture ([`frame`]) --
+//! is computed here, not by the model or the renderer.
+//!
+//! # Licence
+//!
+//! GPL-3.0-or-later, like the rest of the workspace, and OFL-1.1 for the
+//! bundled font (`fonts/OFL-NotoSansKR.txt`).
+
+pub mod dimension;
+pub mod fonts;
+pub mod frame;
+pub mod geom;
+pub mod text;
+
+mod package;
+
+pub use frame::{CropMode, CropSource, Rect};
+pub use package::{
+ compact_string, export_file, export_package, normalize_string, Counts, CropReport,
+ ExcludeReason, Excluded, ExportError, ExportOptions, ExportReport, FrameReport, HeightClass,
+ ImageInfo, LevelInfo, Profile, SheetReport, SheetViewport, WrittenFile, SCHEMA,
+};
diff --git a/crates/uncad-export/src/package/mod.rs b/crates/uncad-export/src/package/mod.rs
new file mode 100644
index 0000000..1df00ca
--- /dev/null
+++ b/crates/uncad-export/src/package/mod.rs
@@ -0,0 +1,2095 @@
+//! The package itself: [`export_package`] writes a directory an agent can
+//! read. See the crate documentation for what it holds.
+
+mod output;
+mod records;
+mod sheets;
+mod tiles;
+
+use std::collections::{BTreeMap, BTreeSet};
+use std::path::{Path, PathBuf};
+
+use iron_render_cad::{
+ limits::Cap, Background, Fonts, Hidden, LeftOutReason, Part, PngError, Scene, Space,
+ ToSvgOptions, View,
+};
+use serde::Serialize;
+use serde_json::{json, Map, Value};
+use uncad_model::model::{Entity, EntityId};
+use uncad_model::{CadDatabase, ToJsonOptions};
+
+use crate::dimension::{
+ cached_labels, display_text, is_angular, measurement_from_points, usable_stored_measurement,
+ DimDefaults, EffectiveStyle,
+};
+use crate::fonts;
+use crate::frame::{
+ auto_padding, crop_source, header_extents, renderer_crop, snap_to_lattice, CropMode,
+ CropSource, Rect, EMPTY_RECT,
+};
+use crate::geom;
+use crate::text::decode_text;
+
+pub use output::WrittenFile;
+use output::{clear_previous_package, sha256_hex, to_rgb, Writer};
+use records::{
+ capped_confidence, coord_decimals, handle_of, id_key, layer_name, placed_texts, text_record,
+ PlacedText, Record, Rounder,
+};
+pub use sheets::{SheetReport, SheetViewport};
+
+/// The `$schema` value every JSON file in the package carries.
+pub const SCHEMA: &str = "uncad-package/1";
+
+/// The stroke width of every image, in pixels.
+const STROKE_PX: f64 = 1.25;
+
+/// The image budget of one model family.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct Profile {
+ pub name: &'static str,
+ /// The overview's longest edge, in pixels.
+ pub overview_edge: u32,
+ /// The most patches (lattice x lattice squares) the overview may hold.
+ pub overview_patches: u32,
+ /// Tile edge, in pixels.
+ pub tile: u32,
+ /// Overlap between neighbouring tiles, in pixels.
+ pub overlap: u32,
+ /// The patch size every image size is rounded up to.
+ pub lattice: u32,
+}
+
+impl Profile {
+ /// Claude's standard tier: 1568 px edge, 1568 patches of 28 px.
+ pub const CLAUDE: Profile = Profile {
+ name: "claude",
+ overview_edge: 1568,
+ overview_patches: 1568,
+ tile: 1092,
+ overlap: 224,
+ lattice: 28,
+ };
+ /// Claude's high-resolution tier.
+ pub const CLAUDE_HIRES: Profile = Profile {
+ name: "claude-hires",
+ overview_edge: 2576,
+ overview_patches: 4784,
+ tile: 1932,
+ overlap: 392,
+ lattice: 28,
+ };
+ /// OpenAI's patch-based models (gpt-5.x): 32 px patches.
+ pub const OPENAI_PATCH: Profile = Profile {
+ name: "openai-patch",
+ overview_edge: 2048,
+ overview_patches: 4096,
+ tile: 1600,
+ overlap: 320,
+ lattice: 32,
+ };
+
+ /// The profiles by name: `claude`, `claude-hires`, `openai-patch`.
+ pub fn by_name(name: &str) -> Option {
+ [
+ Profile::CLAUDE,
+ Profile::CLAUDE_HIRES,
+ Profile::OPENAI_PATCH,
+ ]
+ .into_iter()
+ .find(|p| p.name == name)
+ }
+}
+
+impl Default for Profile {
+ fn default() -> Self {
+ Profile::CLAUDE
+ }
+}
+
+/// What [`export_package`] writes, and how.
+#[derive(Debug, Clone, PartialEq)]
+pub struct ExportOptions {
+ pub profile: Profile,
+ /// The deepest zoom level written (levels are `z1..=z_max`, each 2x the
+ /// previous). Default 5.
+ pub max_levels: u32,
+ /// The most tiles written across all levels; deeper levels are dropped
+ /// whole when this would be exceeded. Default 400.
+ pub max_tiles: usize,
+ /// The pixel height the dominant text class should reach at the deepest
+ /// level. Default 14.
+ pub target_text_px: f64,
+ pub crop: CropMode,
+ /// The padding around the crop, in drawing units, for the overview and
+ /// each frame's window. `None` (the default) is
+ /// [`crate::frame::auto_padding`]: 2 % of the longer side, at least 24
+ /// output pixels. The sheets keep their own zero padding -- a sheet is
+ /// the paper exactly.
+ pub padding: Option,
+ /// Draw hidden entities faded (they never enter the records or the
+ /// crop). Default `false`.
+ pub include_hidden: bool,
+ /// Record files larger than this many KB are sharded. Default 96.
+ pub shard_kb: usize,
+ /// Also write `drawing.svg`. Default `false`.
+ pub svg: bool,
+ /// Also write `entities.json`, the whole model. Default `false`.
+ pub full: bool,
+ /// What the manifest records as the source's name (a file name, say).
+ pub source_name: Option,
+ /// Entities closer than this fraction of the crop's diagonal belong to
+ /// the same group; a detached group becomes its own frame. Default 0.05.
+ pub frame_gap: f64,
+ /// A detached group needs this many entities, or one text, to become a
+ /// frame. A group that does not stays in the overview only and is
+ /// listed in `frames_dropped` with the reason `below_min_entities`.
+ /// Default 20.
+ pub min_frame_entities: usize,
+ /// The most frames written (the primary one included); further groups
+ /// stay in the overview only and are listed in `frames_dropped` with
+ /// the reason `max_frames`. Default 8.
+ pub max_frames: usize,
+ /// The fonts text is drawn and measured with. Default
+ /// [`fonts::bundled()`], the same pixels and text boxes on every
+ /// machine; with any other the capital height is the renderer's default
+ /// (0.7 of the em), since this crate knows only the bundled face's.
+ pub fonts: Fonts,
+ /// Write `sheets.json` and one image per paper layout. Default `true`.
+ pub sheets: bool,
+}
+
+impl Default for ExportOptions {
+ fn default() -> Self {
+ ExportOptions {
+ profile: Profile::CLAUDE,
+ max_levels: 5,
+ max_tiles: 400,
+ target_text_px: 14.0,
+ crop: CropMode::Auto,
+ padding: None,
+ include_hidden: false,
+ shard_kb: 96,
+ svg: false,
+ full: false,
+ source_name: None,
+ frame_gap: 0.05,
+ min_frame_entities: 20,
+ max_frames: 8,
+ fonts: fonts::bundled(),
+ sheets: true,
+ }
+ }
+}
+
+/// Why [`export_package`] or [`export_file`] wrote no package.
+#[derive(Debug)]
+#[non_exhaustive]
+pub enum ExportError {
+ /// A file or directory could not be written.
+ Io {
+ path: PathBuf,
+ source: std::io::Error,
+ },
+ /// The renderer could not draw an image.
+ Render(PngError),
+ /// An image could not be re-encoded as 8-bit RGB.
+ Encode(String),
+ Json(serde_json::Error),
+ /// The whole-model `entities.json` could not be serialized.
+ Model(uncad_model::JsonError),
+ /// [`export_file`] could not read the drawing.
+ Parse(uncad::ParseError),
+}
+
+impl std::fmt::Display for ExportError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ ExportError::Io { path, source } => {
+ write!(f, "cannot write {}: {source}", path.display())
+ }
+ ExportError::Render(e) => write!(f, "rendering failed: {e}"),
+ ExportError::Encode(e) => write!(f, "PNG encoding failed: {e}"),
+ ExportError::Json(e) => write!(f, "JSON serialization failed: {e}"),
+ ExportError::Model(e) => write!(f, "entities.json failed: {e}"),
+ ExportError::Parse(e) => write!(f, "cannot read the drawing: {e}"),
+ }
+ }
+}
+
+impl std::error::Error for ExportError {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match self {
+ ExportError::Io { source, .. } => Some(source),
+ ExportError::Render(e) => Some(e),
+ ExportError::Json(e) => Some(e),
+ ExportError::Model(e) => Some(e),
+ ExportError::Parse(e) => Some(e),
+ ExportError::Encode(_) => None,
+ }
+ }
+}
+
+impl From for ExportError {
+ fn from(e: PngError) -> Self {
+ ExportError::Render(e)
+ }
+}
+
+impl From for ExportError {
+ fn from(e: serde_json::Error) -> Self {
+ ExportError::Json(e)
+ }
+}
+
+impl From for ExportError {
+ fn from(e: uncad_model::JsonError) -> Self {
+ ExportError::Model(e)
+ }
+}
+
+/// One image of the package: where it is and how its pixels map to the
+/// drawing.
+#[derive(Debug, Clone, PartialEq, Serialize)]
+pub struct ImageInfo {
+ pub id: String,
+ pub png: String,
+ /// `[width, height]` in pixels.
+ pub px: [u32; 2],
+ /// The world rectangle the image shows.
+ pub world: Rect,
+ /// Pixels per drawing unit.
+ pub ppu: f64,
+ /// Row-major `[a, b, c, d, e, f]` with `px = a x + b y + c` and
+ /// `py = d x + e y + f`: `[s, 0, -x0 s, 0, -s, y1 s]`.
+ pub world_to_px: [f64; 6],
+ pub px_to_world: [f64; 6],
+}
+
+impl ImageInfo {
+ fn new(id: &str, png: &str, world: Rect, ppu: f64, width: u32, height: u32) -> ImageInfo {
+ ImageInfo {
+ id: id.to_string(),
+ png: png.to_string(),
+ px: [width, height],
+ world,
+ ppu,
+ world_to_px: [ppu, 0.0, -world.min_x * ppu, 0.0, -ppu, world.max_y * ppu],
+ px_to_world: [1.0 / ppu, 0.0, world.min_x, 0.0, -1.0 / ppu, world.max_y],
+ }
+ }
+
+ /// The renderer's view of these pixels: the image's top-left corner at
+ /// the world rectangle's, `ppu` pixels to the unit -- exactly the map
+ /// `world_to_px` publishes.
+ fn view(&self) -> View {
+ View {
+ left: self.world.min_x,
+ top: self.world.max_y,
+ px_per_unit: self.ppu,
+ width: self.px[0],
+ height: self.px[1],
+ }
+ }
+
+ /// A world rectangle in this image's pixels, `[x0, y0, x1, y1]` with y
+ /// down, rounded to whole pixels and **clipped to the image**.
+ ///
+ /// A record is listed on an image whenever its world box merely meets
+ /// it, so the unclipped box of a record that continues past the edge
+ /// named pixels the image does not have. The record's full extent is
+ /// its world `bbox`, and the rest of it is on the other images it
+ /// lists.
+ fn px_box(&self, r: &Rect) -> [i64; 4] {
+ let x0 = ((r.min_x - self.world.min_x) * self.ppu).floor();
+ let x1 = ((r.max_x - self.world.min_x) * self.ppu).ceil();
+ let y0 = ((self.world.max_y - r.max_y) * self.ppu).floor();
+ let y1 = ((self.world.max_y - r.min_y) * self.ppu).ceil();
+ let (w, h) = (i64::from(self.px[0]), i64::from(self.px[1]));
+ let clamp = |v: f64, hi: i64| -> i64 {
+ if v.is_nan() {
+ 0
+ } else {
+ (v.clamp(-9e15, 9e15) as i64).clamp(0, hi)
+ }
+ };
+ [
+ clamp(x0, w),
+ clamp(y0, h),
+ clamp(x1, w).max(clamp(x0, w)),
+ clamp(y1, h).max(clamp(y0, h)),
+ ]
+ }
+}
+
+/// One zoom level of a frame's tile pyramid.
+#[derive(Debug, Clone, PartialEq, Serialize)]
+pub struct LevelInfo {
+ pub z: u32,
+ pub ppu: f64,
+ pub canvas_px: [u32; 2],
+ pub cols: u32,
+ pub rows: u32,
+ pub tile_px: u32,
+ pub overlap_px: u32,
+ pub step_px: u32,
+ pub tiles_written: usize,
+ pub tiles_empty: usize,
+}
+
+/// One text height class of a frame and how legible it is at the deepest
+/// level.
+#[derive(Debug, Clone, PartialEq, Serialize)]
+pub struct HeightClass {
+ pub height: f64,
+ pub count: usize,
+ pub px_at_zmax: f64,
+ pub legible: bool,
+}
+
+/// A frame: a region of the drawing with its own overview and tile pyramid
+/// under `frames//`. `f0` is the primary frame (the largest connected
+/// group of entities); detached groups -- a detail drawn beside the plan --
+/// get `f1`, `f2`, ...
+#[derive(Debug, Clone, PartialEq, Serialize)]
+pub struct FrameReport {
+ pub id: String,
+ /// `primary` or `detached`.
+ pub kind: String,
+ /// The tight bounds of the frame's entities.
+ pub content: Rect,
+ pub entities: usize,
+ pub texts: usize,
+ pub overview: ImageInfo,
+ pub levels: Vec,
+ pub z_max: u32,
+ /// `false` when the tile budget cut the pyramid short.
+ pub reached: bool,
+ pub height_classes: Vec,
+}
+
+/// Why the picture leaves an entity out.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum ExcludeReason {
+ /// Far larger than the rest of the drawing (the renderer's guard, 20x):
+ /// not drawn.
+ ScaleOutlier,
+ /// Far away from the rest of the drawing (20 of its diagonals): not
+ /// drawn.
+ FarOutlier,
+ /// Outside the crop's window.
+ OutsideView,
+}
+
+/// A top-level entity the picture does not show.
+#[derive(Debug, Clone, PartialEq, Serialize)]
+pub struct Excluded {
+ /// Its reference ID in the model.
+ pub id: u64,
+ /// The file's handle for it, when it came from a file.
+ pub handle: Option,
+ pub type_name: String,
+ /// The world box it measured.
+ pub rect: Rect,
+ pub reason: ExcludeReason,
+}
+
+/// What the overview shows and what it leaves out.
+#[derive(Debug, Clone, PartialEq, Serialize)]
+pub struct CropReport {
+ pub source: CropSource,
+ /// The world rectangle the overview shows, padding and lattice growth
+ /// included.
+ pub rect: Rect,
+ /// The tight bounds of the entities inside the crop; `None` when
+ /// nothing is.
+ pub content: Option,
+ /// The padding added on each side, in drawing units (the lattice snap
+ /// may add a little more on the right and bottom).
+ pub padding_units: f64,
+ /// The header's `$EXTMIN/$EXTMAX`, when stated and sane.
+ pub header_extents: Option,
+ /// Entities outside the picture, in drawing order.
+ pub excluded: Vec,
+}
+
+/// How many of each thing the package holds.
+#[derive(Debug, Clone, PartialEq, Default, Serialize)]
+pub struct Counts {
+ /// Top-level entities of model space.
+ pub entities: usize,
+ /// Every text record, model space and paper space together.
+ pub texts: usize,
+ /// The part of `texts` that is paper-space text (a sheet's title
+ /// block): records with `space: "paper"` and a `sheet`.
+ pub texts_paper: usize,
+ pub dimensions: usize,
+ pub geometry: usize,
+ pub regions: usize,
+ pub blocks: usize,
+ /// Every hidden entity the renderer met, those inside block definitions
+ /// included -- the number `report.json` prints as `hidden.count`.
+ pub hidden: usize,
+ pub excluded: usize,
+ pub tiles: usize,
+ pub frames: usize,
+ pub sheets: usize,
+}
+
+/// What [`export_package`] wrote.
+#[derive(Debug, Clone, PartialEq)]
+pub struct ExportReport {
+ pub dir: PathBuf,
+ pub files: Vec,
+ /// The whole crop in one image.
+ pub overview: ImageInfo,
+ /// The frames, `f0` first.
+ pub frames: Vec,
+ /// The paper layouts, in tab order.
+ pub sheets: Vec,
+ pub crop: CropReport,
+ pub counts: Counts,
+ pub warnings: Vec,
+}
+
+/// The most entities `report.json`'s `hidden` names; the file says so with
+/// `handles_limit` and `handles_truncated`.
+const MAX_HIDDEN_NAMED: usize = 100;
+
+/// The most vertices an outline may have before its self-intersection test
+/// is skipped. [`geom::is_simple`] compares every pair of non-adjacent
+/// segments, so the test costs O(n^2): a single closed polyline of 64 000
+/// vertices (a surveyed contour, routine in a GIS import) held the export
+/// for 175 s for one boolean on one record. No outline in the corpus comes
+/// near this cap: the largest region in `AutoCADSamples5.dwg` has 378
+/// vertices.
+const MAX_SIMPLE_TEST_VERTICES: usize = 2_000;
+
+/// What a record says instead of claiming an untested outline is simple.
+const UNTESTED_OUTLINE: &str =
+ "outline too large to test for self-intersection; the area assumes it does not cross itself";
+
+/// Whether `vertices` outline a non-self-intersecting polygon, or `None`
+/// when there are too many of them to ask. Unknown is reported as `null`,
+/// never as `true`: a crossing outline's area is meaningless, and a reader
+/// must be able to tell "checked, fine" from "not checked".
+fn simple_outline(vertices: &[uncad_model::Point2D]) -> Option {
+ (vertices.len() <= MAX_SIMPLE_TEST_VERTICES).then(|| geom::is_simple(vertices))
+}
+
+/// The most pixels one drawing unit may become (see [`fit_overview`]).
+const MAX_PPU: f64 = 1e9;
+
+/// The smallest world window an image of degenerate content gets, in
+/// drawing units. Content with no size at all -- a lone POINT, coincident
+/// entities, the base points of RAY/XLINE, which is all those entities
+/// contribute -- gives the fit nothing to scale to. Such content gets a
+/// window of its own, sized so the picture still reads as a drawing: at ten
+/// units the scale stays in the hundreds of pixels per unit.
+const MIN_CONTENT_EXTENT: f64 = 10.0;
+
+/// An image fitted to the profile: the pixel size within both the edge and
+/// the patch budget, the world rectangle (padded and lattice-snapped) and
+/// the scale.
+struct OverviewFit {
+ rect: Rect,
+ width: u32,
+ height: u32,
+ ppu: f64,
+ padding: f64,
+}
+
+fn fit_overview(content: &Rect, profile: &Profile, padding: Option) -> OverviewFit {
+ // Degenerate content first: a window around its centre, so the rest of
+ // the fit works on a rectangle with a size.
+ let grown;
+ let content = if content.longer_side().is_finite() && content.longer_side() > 0.0 {
+ content
+ } else {
+ let half = MIN_CONTENT_EXTENT / 2.0;
+ let (cx, cy) = if content.min_x.is_finite() && content.min_y.is_finite() {
+ (content.min_x, content.min_y)
+ } else {
+ (0.0, 0.0)
+ };
+ grown = Rect::new(cx - half, cy - half, cx + half, cy + half);
+ &grown
+ };
+ let lattice = f64::from(profile.lattice.max(1));
+ let edge_patches = (f64::from(profile.overview_edge) / lattice)
+ .floor()
+ .max(1.0);
+ let (w, h) = (content.width().max(1e-9), content.height().max(1e-9));
+ let aspect = w / h;
+ let budget = f64::from(profile.overview_patches);
+ let pw = edge_patches.min((budget * aspect).sqrt().floor()).max(1.0);
+ let ph = edge_patches
+ .min((budget / pw).floor())
+ .min((pw / aspect).ceil())
+ .max(1.0);
+ let seed_ppu = (pw * lattice / (1.04 * w)).min(ph * lattice / (1.04 * h));
+ let padding = padding.unwrap_or_else(|| auto_padding(content, Some(seed_ppu)));
+ let padded = content.padded(padding);
+ let ppu = (pw * lattice / padded.width()).min(ph * lattice / padded.height());
+ // A zero-size window (a caller that forced zero padding on degenerate
+ // content) makes `1568 / 0` infinite; the cap also keeps the scale of a
+ // drawing a millionth of a unit across representable.
+ let ppu = if ppu.is_finite() && ppu > 0.0 {
+ ppu.min(MAX_PPU)
+ } else {
+ 1.0
+ };
+ let (rect, width, height) = snap_to_lattice(&padded, ppu, profile.lattice);
+ OverviewFit {
+ rect,
+ width,
+ height,
+ ppu,
+ padding,
+ }
+}
+
+/// The name the package's JSON gives a reason the drawing hides an entity.
+fn hidden_name(h: Hidden) -> &'static str {
+ match h {
+ Hidden::Invisible => "invisible",
+ Hidden::Defpoints => "defpoints",
+ Hidden::LayerOff => "layer_off",
+ Hidden::LayerFrozen => "layer_frozen",
+ Hidden::FrozenInViewport => "frozen_in_viewport",
+ Hidden::LayerNoPlot => "layer_no_plot",
+ _ => "hidden",
+ }
+}
+
+/// The name the package's JSON gives a renderer bound.
+fn cap_name(c: Cap) -> &'static str {
+ match c {
+ Cap::EntityPoints => "entity_points",
+ Cap::DocumentBytes => "document_bytes",
+ Cap::EntityBytes => "entity_bytes",
+ Cap::BlockRefs => "block_refs",
+ Cap::HatchTile => "hatch_tile",
+ Cap::NotANumber => "not_a_number",
+ Cap::OutOfRange => "out_of_range",
+ _ => "other",
+ }
+}
+
+fn area_unit(unit: &str) -> String {
+ format!("{unit}2")
+}
+
+/// The normalisation `strings.json` keys use: Unicode NFKC (so `㎡` is
+/// `m2`, `²` is `2`, full-width digits are ASCII), the fraction slash
+/// U+2044 as `/`, case folded, whitespace collapsed to single spaces.
+pub fn normalize_string(s: &str) -> String {
+ use unicode_normalization::UnicodeNormalization;
+ let folded: String = s.nfkc().collect::().replace('\u{2044}', "/");
+ folded
+ .split_whitespace()
+ .collect::>()
+ .join(" ")
+ .to_lowercase()
+}
+
+/// [`normalize_string`] with every space removed as well, the second key
+/// each string is indexed under (so `32.5 m2` and `32.5m2` meet).
+pub fn compact_string(s: &str) -> String {
+ normalize_string(s).replace(' ', "")
+}
+
+/// Parses the drawing at `input` with its header and writes its package
+/// into `dir` -- [`export_package`] for a file. When `options` names no
+/// source, the manifest records the file's name.
+pub fn export_file(
+ input: &Path,
+ dir: &Path,
+ options: &ExportOptions,
+) -> Result {
+ let (db, header) = uncad::parse_with_header(input).map_err(ExportError::Parse)?;
+ let named;
+ let options = if options.source_name.is_none() {
+ named = ExportOptions {
+ source_name: input.file_name().map(|n| n.to_string_lossy().into_owned()),
+ ..options.clone()
+ };
+ &named
+ } else {
+ options
+ };
+ export_package(&db, Some(&header), dir, options)
+}
+
+/// Writes the package for `db` into `dir` (created if needed). `header` is
+/// the drawing's header as [`uncad::parse_with_header`] returns it -- the
+/// units, the extents the `Auto` crop may take, `$LUPREC` and the dimension
+/// variables a style falls back on; without one the package says its units
+/// are drawing units (`du`) and uses the format's defaults.
+///
+/// A previous uncad package in `dir` is cleared first -- every file its
+/// `manifest.json` listed, and the directories under `frames/` and
+/// `sheets/` that empties -- so a re-export with other options leaves no
+/// stale shards or tiles behind; nothing a manifest did not list is
+/// touched. `manifest.json` is written last: a directory holding one is a
+/// finished package.
+pub fn export_package(
+ db: &CadDatabase,
+ header: Option<&uncad::Header>,
+ dir: &Path,
+ options: &ExportOptions,
+) -> Result {
+ let profile = options.profile;
+ std::fs::create_dir_all(dir).map_err(|source| ExportError::Io {
+ path: dir.to_path_buf(),
+ source,
+ })?;
+ clear_previous_package(dir);
+ let mut warnings: Vec = Vec::new();
+ let fonts = &options.fonts;
+ let cap_height = if *fonts == fonts::bundled() {
+ fonts::UNCAD_SANS_CAP_HEIGHT
+ } else {
+ iron_render_cad::DEFAULT_CAP_HEIGHT
+ };
+
+ // --- render once, and read what the renderer framed -------------------
+ let header_rect = header.and_then(header_extents);
+ let svg_options = ToSvgOptions {
+ space: Space::Model,
+ padding: 0.0,
+ crop: renderer_crop(options.crop, header_rect),
+ include_hidden: options.include_hidden,
+ cap_height,
+ ..ToSvgOptions::default()
+ };
+ let scene = Scene::new(db, svg_options);
+ let parts = scene.parts();
+ let top: BTreeMap = db.entities.iter().map(|e| (e.common().id, e)).collect();
+ // The picture's frame, before padding: the window of a window crop,
+ // the entities' bounds otherwise.
+ let framed: Option = scene.crop.content.map(Rect::from);
+ let window_crop = matches!(options.crop, CropMode::Fixed(_))
+ || (options.crop == CropMode::Header && header_rect.is_some());
+ // What the crop shows. Records cover what the picture shows: not the
+ // hidden entities and not the ones the crop left out (they are listed
+ // in report.json).
+ let shown: Vec<(&Part, &Entity)> = parts
+ .iter()
+ .filter(|p| p.hidden.is_none() && p.left_out.is_none())
+ .filter_map(|p| top.get(&p.id).map(|e| (p, *e)))
+ .collect();
+ let shown_ids: BTreeSet = shown.iter().map(|(p, _)| p.id).collect();
+ let content: Option = if window_crop {
+ Rect::bounding(
+ shown
+ .iter()
+ .filter_map(|(p, _)| p.extent.map(Rect::from))
+ .collect::>()
+ .iter(),
+ )
+ } else {
+ framed
+ };
+ let crop_rect = framed.unwrap_or(EMPTY_RECT);
+ let source = crop_source(
+ options.crop,
+ header_rect,
+ scene.crop.stated_taken,
+ framed.as_ref(),
+ );
+
+ // --- the overview: the whole crop, sized to the profile ----------------
+ let fit = fit_overview(&crop_rect, &profile, options.padding);
+ // The short edge: `fit_overview` pins the long edge at the patch
+ // budget, so the aspect ratio can only squeeze the other one.
+ if fit.width.min(fit.height) < 200 {
+ warnings.push(format!(
+ "TinyOverview: the overview is only {} x {} px; the drawing's aspect leaves little of the patch budget",
+ fit.width, fit.height
+ ));
+ }
+ let overview = ImageInfo::new(
+ "ov",
+ "overview.png",
+ fit.rect,
+ fit.ppu,
+ fit.width,
+ fit.height,
+ );
+ // What the crop leaves out is not drawn either: a 3256x INSERT clipped
+ // by the viewBox would still cross the whole picture.
+ let overview_png =
+ to_rgb(
+ &scene.png(&overview.view(), STROKE_PX, fonts, Background::White, |p| {
+ p.left_out.is_none()
+ })?,
+ )?;
+
+ // --- units and rounding -----------------------------------------------
+ let units = header.and_then(uncad::Header::units);
+ let unit = units.as_ref().map_or("du".to_string(), |u| u.name.clone());
+ let levels = i32::try_from(options.max_levels)
+ .unwrap_or(i32::MAX)
+ .min(64);
+ let coords = coord_decimals(
+ header.and_then(|h| h.luprec).unwrap_or(4),
+ fit.ppu * 2f64.powi(levels),
+ );
+ let rounder = Rounder {
+ coords,
+ derived: coords + 2,
+ };
+
+ // --- texts, measured by the renderer with the package's fonts ----------
+ let boxes = scene.text_boxes(fonts)?;
+ let dimension_ids: BTreeSet = shown
+ .iter()
+ .filter(|(_, e)| matches!(e, Entity::Dimension(_)))
+ .map(|(p, _)| p.id)
+ .collect();
+ // A dimension's label is drawn from its cached block, and is carried by
+ // its own record's `display`.
+ let texts = placed_texts(&db.tables, &top, &boxes, |id| {
+ shown_ids.contains(&id) && !dimension_ids.contains(&id)
+ });
+ let unshaped_texts = texts.iter().filter(|t| t.unshaped > 0).count();
+ if unshaped_texts > 0 {
+ warnings.push(format!(
+ "UnshapedGlyphs: {unshaped_texts} texts hold characters the fonts lack (drawn as boxes)"
+ ));
+ }
+ let measured_why = if *fonts == fonts::bundled() {
+ "glyph outlines as the renderer laid them out, bundled font"
+ } else {
+ "glyph outlines as the renderer laid them out"
+ };
+
+ // --- the drawn extents, widened by the measured texts ------------------
+ // The renderer's extents count a text by its 0.6-em estimate; the text
+ // boxes above are its glyphs. A Hangul syllable advances about 0.92
+ // heights, so a long Korean note runs some 12 % past its estimate, while
+ // `texts.json` lists a text's tiles from its measured box: the frames,
+ // the frame overviews and the tiles are culled by these extents, so each
+ // drawn part grows by the measured boxes of the texts it draws (the part
+ // is the first ID of a text's path). An estimate that was generous is
+ // never shrunk.
+ let mut extent_of: BTreeMap = shown
+ .iter()
+ .filter_map(|(p, _)| p.extent.map(|r| (p.id, Rect::from(r))))
+ .collect();
+ for t in texts.iter().filter(|t| t.measured) {
+ if let Some(e) = extent_of.get_mut(&t.part) {
+ *e = e.union(&t.bbox);
+ }
+ }
+ // In drawing order: the frames are the groups of these.
+ let extents: Vec = shown
+ .iter()
+ .filter_map(|(p, _)| extent_of.get(&p.id).copied())
+ .collect();
+
+ // --- frames: the primary group and each detached group ----------------
+ let plan = tiles::plan_frames(
+ &extents,
+ crop_rect,
+ &texts,
+ &overview,
+ options,
+ &rounder,
+ &mut warnings,
+ );
+ let frame_overviews: Vec<&ImageInfo> = plan
+ .frames
+ .iter()
+ .map(|f| &f.report.overview)
+ .filter(|ov| ov.id != "ov")
+ .collect();
+ let mut tile_images: Vec = Vec::new();
+ for build in &plan.frames {
+ for level in &build.report.levels {
+ for tile in build.tiles.iter().filter(|t| t.z == level.z && !t.empty) {
+ let png = format!(
+ "frames/{}/tiles/z{}/r{:02}_c{:02}.png",
+ tile.frame, tile.z, tile.row, tile.col
+ );
+ tile_images.push(ImageInfo::new(
+ &tile.id,
+ &png,
+ tile.world,
+ level.ppu,
+ tile.width,
+ tile.height,
+ ));
+ }
+ }
+ }
+ // Every image but the overview, drawn on as many threads as there are:
+ // each is a window of the one walk, holding only the parts that reach
+ // it.
+ let to_draw: Vec<&ImageInfo> = frame_overviews
+ .iter()
+ .copied()
+ .chain(tile_images.iter())
+ .collect();
+ let drawn = tiles::render_images(&scene, fonts, &extent_of, &to_draw)?;
+
+ // --- sheets: one image per paper layout ------------------------------------
+ let sheets = if options.sheets {
+ sheets::export_sheets(db, &unit, options, cap_height, &rounder, &mut warnings)?
+ } else {
+ sheets::Sheets::default()
+ };
+
+ // --- records with their images -------------------------------------------
+ let images_for = |bbox: &Rect| -> Vec<&ImageInfo> {
+ tile_images
+ .iter()
+ .filter(|img| img.world.intersects(bbox))
+ .collect()
+ };
+ let px_map = |bbox: &Rect| -> Value {
+ let mut m = Map::new();
+ m.insert("ov".to_string(), json!(overview.px_box(bbox)));
+ for ov in frame_overviews.iter().filter(|o| o.world.intersects(bbox)) {
+ m.insert(ov.id.clone(), json!(ov.px_box(bbox)));
+ }
+ for img in images_for(bbox) {
+ m.insert(img.id.clone(), json!(img.px_box(bbox)));
+ }
+ Value::Object(m)
+ };
+ let tiles_for =
+ |bbox: &Rect| -> Vec { images_for(bbox).iter().map(|i| i.id.clone()).collect() };
+ let with_images = |mut r: Record| -> Record {
+ r.value.insert("tiles".into(), json!(tiles_for(&r.bbox)));
+ r.value.insert("px".into(), px_map(&r.bbox));
+ r
+ };
+
+ let mut text_records: Vec = texts
+ .iter()
+ .map(|t| with_images(text_record(t, &rounder, "model", None, measured_why)))
+ .collect();
+ text_records.sort_by_cached_key(|r| id_key(&r.id));
+ // The paper-space texts, each with its sheet and that sheet image's
+ // pixel box -- the only route in the package from a record to a sheet
+ // pixel. They are kept out of `text_records` proper because that list
+ // is what the tile sidecars are built from, and a paper box in paper
+ // units would otherwise be matched against model tiles.
+ let paper_text_records: Vec = sheets
+ .texts
+ .iter()
+ .map(|(t, sheet)| {
+ let report = &sheets.reports[*sheet];
+ let mut r = text_record(t, &rounder, "paper", Some(&report.name), measured_why);
+ let mut px = Map::new();
+ px.insert(
+ report.overview.id.clone(),
+ json!(report.overview.px_box(&t.bbox)),
+ );
+ r.value.insert("tiles".into(), json!([]));
+ r.value.insert("px".into(), Value::Object(px));
+ r
+ })
+ .collect();
+ // Model and paper texts in one record file, ordered by id like every
+ // other kind, so `shard_index` resolves either of them.
+ let mut written_texts: Vec = text_records
+ .iter()
+ .chain(paper_text_records.iter())
+ .cloned()
+ .collect();
+ written_texts.sort_by_cached_key(|r| id_key(&r.id));
+
+ let defaults = header.map(DimDefaults::from_header).unwrap_or_default();
+ let dim_records = dimension_records(db, &shown, &extent_of, &defaults, &unit, &rounder)?
+ .into_iter()
+ .map(&with_images)
+ .collect::>();
+ let (geo_records, region_records) =
+ geometry_records(&shown, &extent_of, &texts, units.as_ref(), &unit, &rounder);
+ let geo_records: Vec = geo_records.into_iter().map(&with_images).collect();
+ let region_records: Vec = region_records.into_iter().map(&with_images).collect();
+ let (block_records, instances) = block_records(&shown, &extent_of, &rounder);
+ let block_records: Vec = block_records.into_iter().map(&with_images).collect();
+
+ // --- strings -------------------------------------------------------------
+ let mut strings: BTreeMap> = BTreeMap::new();
+ let mut index = |text: &str, id: &str| {
+ let key = normalize_string(text);
+ if key.is_empty() {
+ return;
+ }
+ let compact = compact_string(text);
+ if compact != key {
+ strings.entry(compact).or_default().insert(id.to_string());
+ }
+ strings.entry(key).or_default().insert(id.to_string());
+ };
+ for t in texts.iter().chain(sheets.texts.iter().map(|(t, _)| t)) {
+ index(&t.text, &t.id);
+ }
+ for r in &dim_records {
+ if let Some(Value::String(d)) = r.value.get("display") {
+ index(d, &r.id);
+ }
+ }
+ for r in &block_records {
+ if let Some(Value::Object(attribs)) = r.value.get("attribs") {
+ for value in attribs.values() {
+ if let Value::String(s) = value {
+ index(s, &r.id);
+ }
+ }
+ }
+ }
+ let frame_reports: Vec = plan.frames.iter().map(|b| b.report.clone()).collect();
+ let written_total: usize = frame_reports
+ .iter()
+ .flat_map(|f| f.levels.iter())
+ .map(|l| l.tiles_written)
+ .sum();
+
+ // --- write -----------------------------------------------------------------
+ let mut writer = Writer {
+ dir,
+ files: Vec::new(),
+ shard_index: Vec::new(),
+ units: json!({
+ "name": unit,
+ "insunits": header.and_then(|h| h.insunits),
+ "to_mm": units.as_ref().and_then(|u| u.to_mm),
+ "source": if units.is_some() { "header" } else { "none" },
+ }),
+ shard_kb: options.shard_kb,
+ };
+ writer.write_bytes("overview.png", &overview_png, "image")?;
+ // Tile id -> its PNG's byte count and SHA-256, for `tiles.json`.
+ let mut tile_png: BTreeMap<&str, (u64, String)> = BTreeMap::new();
+ for (image, bytes) in to_draw.iter().zip(&drawn) {
+ let is_tile = !frame_overviews.iter().any(|ov| ov.id == image.id);
+ if is_tile {
+ tile_png.insert(&image.id, (bytes.len() as u64, sha256_hex(bytes)));
+ }
+ writer.write_bytes(&image.png, bytes, if is_tile { "tile" } else { "image" })?;
+ }
+ for (path, bytes) in &sheets.images {
+ writer.write_bytes(path, bytes, "sheet")?;
+ }
+ writer.write_records("texts", "text", &written_texts)?;
+ writer.write_records("dimensions", "dimension", &dim_records)?;
+ writer.write_records("geometry", "geometry", &geo_records)?;
+ writer.write_records("regions", "region", ®ion_records)?;
+ // The INSERT instances are records like any other kind: they go through
+ // the same shard rule and land in `shard_index`. The definitions -- a
+ // table, not a per-entity record -- are in drawing.json.
+ writer.write_records("blocks", "blocks", &block_records)?;
+ writer.write_json(
+ "strings.json",
+ &json!({
+ "$schema": SCHEMA,
+ "normalization": "NFKC, fraction slash to '/', case fold, whitespace collapsed; each string also under its key with spaces removed",
+ "ids": "record ids: the drawing's entity reference ID as a decimal number, or `/` for a text drawn inside a block. The kind is not in the id -- resolve it with manifest.shard_index, whose `first_key`/`last_key` bracket each file's ids by their first number (`int(id.split('/')[0])`). One id can be in two kinds at once: a closed polyline is both a geometry and a region record.",
+ "strings": strings,
+ }),
+ "strings",
+ )?;
+
+ if options.sheets {
+ writer.write_json(
+ "sheets.json",
+ &json!({
+ "$schema": SCHEMA,
+ // Stated in the fields this file publishes.
+ "model_to_paper": "Per composited viewport, `model_to_paper` is the map the model was drawn through: row-major [a, b, c, d, e, f] with paper_x = a x + b y + c and paper_y = d x + e y + f for a model point (x, y) -- a rotation by `twist_deg` and a scale of `scale` paper units per model unit, which sends the four `model_window` corners (lower-left, lower-right, upper-right, upper-left as they sit on the sheet) onto the four corners of its `frame`. With twist_deg 0 that is paper = [frame[0] + (x - model_window[0][0]) * scale, frame[1] + (y - model_window[0][1]) * scale]. Then apply this sheet's overview `world_to_px` to get pixels in sheets//overview.png.",
+ "twist_convention": "`twist_deg` is the viewport's VIEWTWIST in degrees, positive turning the model counter-clockwise on the sheet (ezdxf's rule; not checked against a plotted sheet).",
+ "sheets": sheets.reports,
+ }),
+ "sheets",
+ )?;
+ }
+
+ // --- sidecars and tiles.json -------------------------------------------------
+ let on_tiles = tiles::OnTiles {
+ texts: &text_records,
+ dims: &dim_records,
+ blocks: &block_records,
+ regions: ®ion_records,
+ geometry: &geo_records,
+ };
+ let mut tiles_json: Vec = Vec::new();
+ for build in &plan.frames {
+ for tile in &build.tiles {
+ let mut entry = json!({
+ "id": tile.id,
+ "frame": tile.frame,
+ "z": tile.z,
+ "row": tile.row,
+ "col": tile.col,
+ "px": [tile.width, tile.height],
+ "world": rounder.rect(&tile.world),
+ "empty": tile.empty,
+ });
+ if tile.empty {
+ // One reason a planned tile is not written: nothing visible
+ // reaches it. A reader following `children` or a record's
+ // `tiles` must know an absent file was meant to be absent.
+ entry["reason"] = json!("no_visible_entity_on_tile");
+ }
+ if let Some(img) = tile_images.iter().find(|i| i.id == tile.id) {
+ entry["png"] = json!(img.png);
+ if let Some((bytes, sha)) = tile_png.get(img.id.as_str()) {
+ entry["bytes"] = json!(bytes);
+ entry["sha256"] = json!(sha);
+ }
+ let sidecar =
+ tiles::sidecar(img, tile, &build.tiles, &profile, &on_tiles, &rounder);
+ let sidecar_path = img.png.replace(".png", ".json");
+ writer.write_json_compact(&sidecar_path, &sidecar, "sidecar")?;
+ entry["sidecar"] = json!(sidecar_path);
+ }
+ tiles_json.push(entry);
+ }
+ }
+ writer.write_json(
+ "tiles.json",
+ &json!({
+ "$schema": SCHEMA,
+ "frames": frame_reports.iter().map(|f| json!({ "id": f.id, "kind": f.kind, "content": rounder.rect(&f.content), "levels": f.levels })).collect::>(),
+ "tiles": tiles_json,
+ }),
+ "tiles",
+ )?;
+ writer.write_json(
+ "drawing.json",
+ &drawing_json(db, header, parts, &top, &instances, &writer.units)?,
+ "drawing",
+ )?;
+
+ // optional whole-model files
+ let mut svg_origin: Option<[f64; 2]> = None;
+ if options.svg {
+ let window = crop_rect.padded(auto_padding(&crop_rect, None));
+ let stroke = (window.diagonal() / 6000.0).max(0.01);
+ let svg = scene.svg(window.into(), stroke, |p| p.left_out.is_none());
+ writer.write_bytes("drawing.svg", svg.as_bytes(), "svg")?;
+ svg_origin = Some([scene.origin.x, scene.origin.y]);
+ }
+ if options.full {
+ let text = db.to_json(ToJsonOptions { pretty: false })?;
+ writer.write_bytes("entities.json", text.as_bytes(), "entities")?;
+ }
+
+ // --- report.json -------------------------------------------------------
+ let mut hidden_by_reason: BTreeMap<&str, usize> = BTreeMap::new();
+ let mut hidden_named: Vec<(u64, Value)> = Vec::new();
+ for p in parts {
+ if let Some(reason) = p.hidden {
+ *hidden_by_reason.entry(hidden_name(reason)).or_default() += 1;
+ if hidden_named.len() < MAX_HIDDEN_NAMED {
+ let handle = top
+ .get(&p.id)
+ .map_or(Value::Null, |e| handle_of(e.common()));
+ hidden_named.push((p.id.value(), handle));
+ }
+ }
+ }
+ // Every bound the model render and each sheet render engaged, summed
+ // field by field: what a malformed file cost the package.
+ let mut limits = scene.limits.clone();
+ for l in &sheets.limits {
+ limits.oversized_entities += l.oversized_entities;
+ limits.block_refs_dropped += l.block_refs_dropped;
+ limits.hatch_patterns_dropped += l.hatch_patterns_dropped;
+ limits.entities_dropped += l.entities_dropped;
+ limits.truncated_parts += l.truncated_parts;
+ limits.unreadable_entities += l.unreadable_entities;
+ limits.out_of_range_entities += l.out_of_range_entities;
+ for d in &l.dropped {
+ if limits.dropped.len() < MAX_HIDDEN_NAMED && !limits.dropped.contains(d) {
+ limits.dropped.push(d.clone());
+ }
+ }
+ }
+ let limits = &limits;
+ if limits.engaged() {
+ warnings.push(format!(
+ "the drawing hit the renderer's robustness limits: {}",
+ limits_summary(limits)
+ ));
+ }
+ let excluded: Vec = scene
+ .crop
+ .left_out
+ .iter()
+ .map(|l| Excluded {
+ id: l.id.value(),
+ handle: top
+ .get(&l.id)
+ .and_then(|e| e.common().source_handle.resolved().cloned()),
+ type_name: l.type_name.clone(),
+ rect: Rect::from(l.extent),
+ reason: match l.reason {
+ LeftOutReason::ScaleOutlier => ExcludeReason::ScaleOutlier,
+ LeftOutReason::FarOutlier => ExcludeReason::FarOutlier,
+ _ => ExcludeReason::OutsideView,
+ },
+ })
+ .collect();
+ let crop_report = CropReport {
+ source,
+ rect: fit.rect,
+ content,
+ padding_units: fit.padding,
+ header_extents: header_rect,
+ excluded,
+ };
+ // "What is missing from the picture, and why" is one list: the crop's
+ // own exclusions plus every entity a robustness bound acted on, each
+ // naming the bound under `reason`.
+ let excluded_json: Vec = crop_report
+ .excluded
+ .iter()
+ .map(|e| json!(e))
+ .chain(limits.dropped.iter().map(|d| {
+ json!({
+ "id": d.id.value(),
+ "handle": top.get(&d.id).map_or(Value::Null, |e| handle_of(e.common())),
+ "type_name": d.type_name,
+ "reason": cap_name(d.cap),
+ })
+ }))
+ .collect();
+ let counts = Counts {
+ entities: parts.len(),
+ texts: written_texts.len(),
+ texts_paper: paper_text_records.len(),
+ dimensions: dim_records.len(),
+ geometry: geo_records.len(),
+ regions: region_records.len(),
+ blocks: block_records.len(),
+ hidden: scene.hidden,
+ excluded: excluded_json.len(),
+ tiles: written_total,
+ frames: frame_reports.len(),
+ sheets: sheets.reports.len(),
+ };
+ let hidden_top_level: usize = hidden_by_reason.values().sum();
+ // `count` is the number `manifest.counts.hidden` prints, every hidden
+ // entity the renderer met; the split says how many of them the
+ // top-level reasons cover.
+ let report_value = json!({
+ "$schema": SCHEMA,
+ "excluded": excluded_json,
+ "hidden": {
+ "count": scene.hidden,
+ "top_level": hidden_top_level,
+ "inside_blocks": scene.hidden.saturating_sub(hidden_top_level),
+ "covers": "top_level",
+ "by_reason": hidden_by_reason,
+ "ids": hidden_named.iter().map(|(id, _)| *id).collect::>(),
+ "handles": hidden_named.iter().map(|(_, h)| h.clone()).collect::>(),
+ "handles_limit": MAX_HIDDEN_NAMED,
+ "handles_truncated": hidden_top_level > hidden_named.len(),
+ },
+ "unsupported_types": scene.unsupported_types,
+ "empty_blocks": scene.empty_blocks,
+ "unresolved_block_refs": scene.unresolved_block_refs.iter().map(|id| id.value()).collect::>(),
+ "limits": limits_json(limits, &top),
+ "warnings": warnings,
+ });
+ writer.files.push(WrittenFile {
+ path: "report.json".into(),
+ bytes: None,
+ kind: "report".into(),
+ });
+
+ // --- manifest.json and README.txt, last (they list the files) ------------
+ let manifest = manifest_json(ManifestInput {
+ header,
+ options,
+ writer: &mut writer,
+ crop: &crop_report,
+ svg_origin,
+ overview: &overview,
+ frames: &frame_reports,
+ frames_dropped: &plan.dropped,
+ frames_dropped_total: plan.dropped_total,
+ rounder: &rounder,
+ counts: &counts,
+ texts: &written_texts,
+ paper_texts: &paper_text_records,
+ sheets: &sheets.reports,
+ dims: &dim_records,
+ regions: ®ion_records,
+ warnings: &warnings,
+ });
+ let manifest_text = serde_json::to_string_pretty(&manifest)?;
+ let readme = readme(options);
+ std::fs::write(dir.join("README.txt"), readme.as_bytes()).map_err(|source| {
+ ExportError::Io {
+ path: dir.join("README.txt"),
+ source,
+ }
+ })?;
+ let report_text = serde_json::to_string_pretty(&report_value)?;
+ std::fs::write(dir.join("report.json"), &report_text).map_err(|source| ExportError::Io {
+ path: dir.join("report.json"),
+ source,
+ })?;
+ // manifest.json is written last, after every file it names: a directory
+ // that holds one is a finished package, and one that does not is
+ // nothing a reader should trust. (It is also what the next run's
+ // `clear_previous_package` reads.)
+ std::fs::write(dir.join("manifest.json"), &manifest_text).map_err(|source| {
+ ExportError::Io {
+ path: dir.join("manifest.json"),
+ source,
+ }
+ })?;
+
+ Ok(ExportReport {
+ dir: dir.to_path_buf(),
+ files: writer.files,
+ overview,
+ frames: frame_reports,
+ sheets: sheets.reports,
+ crop: crop_report,
+ counts,
+ warnings,
+ })
+}
+
+/// The robustness bounds a render engaged, in a sentence.
+fn limits_summary(l: &iron_render_cad::limits::LimitReport) -> String {
+ let mut parts = Vec::new();
+ for (n, what) in [
+ (l.oversized_entities, "entities with too many points"),
+ (
+ l.block_refs_dropped,
+ "block references past the depth or expansion budget",
+ ),
+ (l.hatch_patterns_dropped, "hatch patterns too fine to draw"),
+ (l.entities_dropped, "entities past the document budget"),
+ (l.truncated_parts, "entities drawn only in part"),
+ (
+ l.unreadable_entities,
+ "entities with coordinates that are not numbers",
+ ),
+ (l.out_of_range_entities, "entities too far from the origin"),
+ ] {
+ if n > 0 {
+ parts.push(format!("{n} {what}"));
+ }
+ }
+ parts.join(", ")
+}
+
+fn limits_json(
+ l: &iron_render_cad::limits::LimitReport,
+ top: &BTreeMap,
+) -> Value {
+ json!({
+ "oversized_entities": l.oversized_entities,
+ "block_refs_dropped": l.block_refs_dropped,
+ "hatch_patterns_dropped": l.hatch_patterns_dropped,
+ "entities_dropped": l.entities_dropped,
+ "truncated_parts": l.truncated_parts,
+ "unreadable_entities": l.unreadable_entities,
+ "out_of_range_entities": l.out_of_range_entities,
+ "dropped": l.dropped.iter().map(|d| json!({
+ "id": d.id.value(),
+ "handle": top.get(&d.id).map_or(Value::Null, |e| handle_of(e.common())),
+ "type_name": d.type_name,
+ "cap": cap_name(d.cap),
+ })).collect::>(),
+ })
+}
+
+/// The dimension records: every shown DIMENSION with its value, where the
+/// value came from, the value its points give, the label and its source.
+fn dimension_records(
+ db: &CadDatabase,
+ shown: &[(&Part, &Entity)],
+ extent_of: &BTreeMap,
+ defaults: &DimDefaults,
+ unit: &str,
+ rounder: &Rounder,
+) -> Result, ExportError> {
+ let labels = cached_labels(&db.tables);
+ let mut out = Vec::new();
+ for (part, e) in shown {
+ let Entity::Dimension(d) = e else { continue };
+ let bbox = extent_of.get(&part.id).copied().unwrap_or_else(|| {
+ let p = d.definition_point.unwrap_or_default();
+ Rect::at(p.x, p.y)
+ });
+ let angular = is_angular(d.kind);
+ let from_points = measurement_from_points(d);
+ let stored = usable_stored_measurement(d.measurement, d.kind, from_points);
+ // `confidence` follows the source, not merely "there is a number": a
+ // value the definition points gave is exact arithmetic on what the
+ // file stores, but it is not what the drawing was measured at, and
+ // a reader deciding whether to trust the number over the label
+ // needs the two kept apart.
+ let (measurement, source, confidence) = match (stored, from_points) {
+ (Some(m), _) => (Some(m), "act_measurement", "stored"),
+ (None, Some(p)) => (Some(p), "from_points", "exact"),
+ (None, None) => (None, "none", "unavailable"),
+ };
+ let delta = match (stored, from_points) {
+ (Some(m), Some(p)) => Some(rounder.derived(m - p)),
+ _ => None,
+ };
+ let style = EffectiveStyle::resolve(
+ d.style_name
+ .resolved()
+ .and_then(|name| db.tables.dim_styles.get(name)),
+ defaults,
+ );
+ let shown_text = display_text(d, &style, measurement, labels.get(d.block_name.name()));
+ let (confidence, why) = capped_confidence(confidence, d.common.confidence);
+ let point = |p: Option| p.map_or(Value::Null, |p| rounder.pt3(p));
+ let mut v = Map::new();
+ v.insert("id".into(), json!(d.common.id.value().to_string()));
+ v.insert("handle".into(), handle_of(&d.common));
+ v.insert("kind".into(), serde_json::to_value(d.kind)?);
+ v.insert("layer".into(), json!(layer_name(&d.common.layer)));
+ v.insert("dimstyle".into(), json!(d.style_name.name()));
+ v.insert(
+ "measurement".into(),
+ json!(measurement.map(|m| rounder.derived(m))),
+ );
+ v.insert("measurement_source".into(), json!(source));
+ v.insert(
+ "measurement_from_points".into(),
+ json!(from_points.map(|m| rounder.derived(m))),
+ );
+ v.insert("delta".into(), json!(delta));
+ v.insert("unit".into(), json!(if angular { "deg" } else { unit }));
+ v.insert("dimlfac".into(), json!(style.dimlfac));
+ v.insert("display".into(), json!(shown_text.text));
+ if shown_text.raw != shown_text.text && !shown_text.raw.is_empty() {
+ v.insert("display_raw".into(), json!(shown_text.raw));
+ }
+ v.insert(
+ "display_source".into(),
+ serde_json::to_value(shown_text.source)?,
+ );
+ if let uncad_model::model::TextOverride::Literal(s) = &d.text_override {
+ v.insert("user_text".into(), json!(s));
+ }
+ v.insert(
+ "points".into(),
+ json!({
+ "definition": point(d.definition_point),
+ "extension1": point(d.points.extension1),
+ "extension2": point(d.points.extension2),
+ "radial": point(d.points.radial),
+ "arc": point(d.points.arc),
+ }),
+ );
+ if d.kind == Some(uncad_model::model::DimensionKind::Rotated) {
+ v.insert(
+ "rotation_deg".into(),
+ json!(rounder.derived(d.rotation.to_degrees())),
+ );
+ }
+ if let Some(axis) = d.ordinate_axis {
+ v.insert("ordinate_axis".into(), serde_json::to_value(axis)?);
+ }
+ v.insert("definition_point".into(), point(d.definition_point));
+ v.insert("text_at".into(), rounder.pt2(d.text_midpoint));
+ v.insert("confidence".into(), json!(confidence));
+ if let Some(why) = why {
+ v.insert("why".into(), json!(why));
+ }
+ v.insert("bbox".into(), rounder.rect(&bbox));
+ out.push(Record {
+ id: d.common.id.value().to_string(),
+ bbox,
+ value: v,
+ });
+ }
+ out.sort_by_cached_key(|r| id_key(&r.id));
+ Ok(out)
+}
+
+/// The geometry records (every shown entity that is not a text, a
+/// dimension or a block instance, and measured an extent) and the region
+/// records (the closed polylines among them that enclose an area).
+fn geometry_records(
+ shown: &[(&Part, &Entity)],
+ extent_of: &BTreeMap,
+ texts: &[PlacedText],
+ units: Option<&uncad::Units>,
+ unit: &str,
+ rounder: &Rounder,
+) -> (Vec, Vec) {
+ use std::f64::consts::{PI, TAU};
+ let mut geometry: Vec = Vec::new();
+ let mut regions: Vec = Vec::new();
+ // The outlines of the regions, world coordinates, for their labels.
+ let mut outlines: BTreeMap> = BTreeMap::new();
+ for (part, e) in shown {
+ if matches!(
+ e,
+ Entity::Text(_)
+ | Entity::MText(_)
+ | Entity::Attrib(_)
+ | Entity::Attdef(_)
+ | Entity::Dimension(_)
+ | Entity::Insert(_)
+ ) {
+ continue;
+ }
+ let Some(bbox) = extent_of.get(&part.id).copied() else {
+ continue;
+ };
+ let id = part.id.value().to_string();
+ let mut v = Map::new();
+ v.insert("id".into(), json!(id));
+ v.insert("handle".into(), handle_of(e.common()));
+ v.insert("type".into(), json!(e.type_name()));
+ v.insert("layer".into(), json!(layer_name(&e.common().layer)));
+ let mut confidence = "exact";
+ let mut why: Option<&str> = None;
+ match e {
+ Entity::Line(l) => {
+ v.insert("from".into(), rounder.pt3(l.start_point));
+ v.insert("to".into(), rounder.pt3(l.end_point));
+ // `length` is the length the file holds: the 3D distance.
+ // `length_plan` and `dz` are added only when the line leaves
+ // the plane, the only signal in an otherwise planar record
+ // that it does.
+ let (dx, dy, dz) = (
+ l.end_point.x - l.start_point.x,
+ l.end_point.y - l.start_point.y,
+ l.end_point.z - l.start_point.z,
+ );
+ let plan = dx.hypot(dy);
+ v.insert("length".into(), json!(rounder.derived(plan.hypot(dz))));
+ if rounder.derived(dz) != 0.0 {
+ v.insert("length_plan".into(), json!(rounder.derived(plan)));
+ v.insert("dz".into(), json!(rounder.derived(dz)));
+ }
+ }
+ Entity::Arc(a) => {
+ // Its own plane to the world: a mirrored arc (normal
+ // (0,0,-1)) runs clockwise there, so its world arc runs
+ // counter-clockwise from the image of its end to the image
+ // of its start.
+ let plane = geom::plane_to_world(a.extrusion, a.center.z);
+ let center = plane.apply(uncad_model::Point2D {
+ x: a.center.x,
+ y: a.center.y,
+ });
+ let (start, end) = if geom::is_similarity(&plane) {
+ let turn = |angle: f64| {
+ let (s, c) = angle.sin_cos();
+ (plane.b * c + plane.d * s).atan2(plane.a * c + plane.c * s)
+ };
+ if plane.determinant() < 0.0 {
+ (turn(a.end_angle), turn(a.start_angle))
+ } else {
+ (turn(a.start_angle), turn(a.end_angle))
+ }
+ } else {
+ confidence = "estimated";
+ why = Some("drawn in a tilted plane: the centre is its plan projection, the angles are the arc's own");
+ (a.start_angle, a.end_angle)
+ };
+ let mut sweep = end - start;
+ if sweep <= 0.0 {
+ sweep += TAU;
+ }
+ v.insert("center".into(), rounder.pt2(center));
+ v.insert("r".into(), json!(rounder.derived(a.radius)));
+ v.insert(
+ "start_deg".into(),
+ json!(rounder.derived(start.to_degrees())),
+ );
+ v.insert("end_deg".into(), json!(rounder.derived(end.to_degrees())));
+ v.insert(
+ "sweep_deg".into(),
+ json!(rounder.derived(sweep.to_degrees())),
+ );
+ v.insert("length".into(), json!(rounder.derived(a.radius * sweep)));
+ }
+ Entity::Circle(c) => {
+ let plane = geom::plane_to_world(c.extrusion, c.center.z);
+ let center = plane.apply(uncad_model::Point2D {
+ x: c.center.x,
+ y: c.center.y,
+ });
+ if !geom::is_similarity(&plane) {
+ why = Some("drawn in a tilted plane: the centre is its plan projection");
+ }
+ v.insert("center".into(), rounder.pt2(center));
+ v.insert("r".into(), json!(rounder.derived(c.radius)));
+ v.insert("length".into(), json!(rounder.derived(TAU * c.radius)));
+ v.insert(
+ "area".into(),
+ json!(rounder.derived(PI * c.radius * c.radius)),
+ );
+ }
+ Entity::Ellipse(el) => {
+ let a = el.major_axis_endpoint.x.hypot(el.major_axis_endpoint.y);
+ v.insert("center".into(), rounder.pt3(el.center));
+ v.insert("major_axis".into(), rounder.pt3(el.major_axis_endpoint));
+ v.insert("ratio".into(), json!(rounder.derived(el.axis_ratio)));
+ let full = (el.end_angle - el.start_angle - TAU).abs() < 1e-9
+ || (el.start_angle == 0.0 && el.end_angle == 0.0);
+ if full {
+ v.insert(
+ "area".into(),
+ json!(rounder.derived(PI * a * a * el.axis_ratio)),
+ );
+ } else {
+ confidence = "unavailable";
+ why = Some("elliptical arc length is not computed");
+ }
+ }
+ Entity::LwPolyline(p) | Entity::Polyline2D(p) => {
+ // The vertices in the world, when the plane maps arcs to
+ // arcs; a tilted plane's are projected, and its lengths and
+ // area are measured in its own plane instead.
+ let plane = geom::plane_to_world(p.extrusion, p.elevation);
+ let world = geom::map_polyline(&p.vertices, &plane);
+ let tilted = world.is_none();
+ let world_vertices = world.unwrap_or_else(|| {
+ p.vertices
+ .iter()
+ .map(|v| uncad_model::PolylineVertex {
+ point: plane.apply(v.point),
+ ..*v
+ })
+ .collect()
+ });
+ let measured_on = if tilted { &p.vertices } else { &world_vertices };
+ let bulged = world_vertices.iter().any(|v| v.bulge != 0.0);
+ v.insert("closed".into(), json!(p.closed));
+ v.insert(
+ "vfmt".into(),
+ json!(if bulged { "[x,y,bulge]" } else { "[x,y]" }),
+ );
+ let vertices: Vec = world_vertices
+ .iter()
+ .map(|vx| {
+ if bulged {
+ json!([
+ rounder.coord(vx.point.x),
+ rounder.coord(vx.point.y),
+ vx.bulge
+ ])
+ } else {
+ rounder.pt2(vx.point)
+ }
+ })
+ .collect();
+ v.insert("vertices".into(), json!(vertices));
+ let length = geom::polyline_length(measured_on, p.closed);
+ v.insert(
+ if p.closed { "perimeter" } else { "length" }.into(),
+ json!(rounder.derived(length)),
+ );
+ if tilted {
+ confidence = "estimated";
+ why = Some("drawn in a tilted plane: the vertices are their plan projection, the length and area are measured in the entity's own plane");
+ }
+ if let Some(area) = geom::polyline_area(measured_on, p.closed) {
+ let signed = geom::polyline_signed_area(&world_vertices, p.closed);
+ let points: Vec =
+ world_vertices.iter().map(|v| v.point).collect();
+ let simple = simple_outline(&points);
+ v.insert("area".into(), json!(rounder.derived(area)));
+ v.insert(
+ "orientation".into(),
+ json!(if signed >= 0.0 { "ccw" } else { "cw" }),
+ );
+ v.insert("simple".into(), json!(simple));
+ let outline_why = match simple {
+ Some(true) => None,
+ Some(false) => {
+ confidence = "unavailable";
+ Some("self-intersecting outline: the area has no meaning")
+ }
+ None => {
+ if confidence == "exact" {
+ confidence = "estimated";
+ }
+ Some(UNTESTED_OUTLINE)
+ }
+ };
+ why = outline_why.or(why);
+ // Whatever the area could be measured of gets a region
+ // record: a closed two-vertex bulged polyline (AutoCAD's
+ // DONUT) is as much a closed shape as a triangle.
+ if p.closed && p.vertices.len() >= 2 {
+ let (region_confidence, capped) =
+ capped_confidence(confidence, e.common().confidence);
+ let mut r = Map::new();
+ r.insert("id".into(), json!(id));
+ r.insert("handle".into(), handle_of(e.common()));
+ r.insert("src".into(), json!(e.type_name()));
+ r.insert("layer".into(), json!(layer_name(&e.common().layer)));
+ r.insert("area".into(), json!(rounder.derived(area)));
+ r.insert("area_unit".into(), json!(area_unit(unit)));
+ r.insert(
+ "area_si".into(),
+ json!(units
+ .and_then(|u| u.to_mm)
+ .map(|mm| rounder.derived(area * mm * mm / 1e6))),
+ );
+ r.insert("perimeter".into(), json!(rounder.derived(length)));
+ r.insert(
+ "centroid".into(),
+ rounder.pt2(geom::polygon_centroid(&points)),
+ );
+ r.insert("vertex_count".into(), json!(p.vertices.len()));
+ r.insert("simple".into(), json!(simple));
+ // The same `confidence` *and the same `why`* as the
+ // geometry record this region is built from.
+ r.insert("confidence".into(), json!(region_confidence));
+ if let Some(w) = capped.or(why) {
+ r.insert("why".into(), json!(w));
+ }
+ r.insert("bbox".into(), rounder.rect(&bbox));
+ regions.push(Record {
+ id: id.clone(),
+ bbox,
+ value: r,
+ });
+ if points.len() >= 3 {
+ outlines.insert(id.clone(), points);
+ }
+ }
+ }
+ }
+ Entity::Polyline3D(p) => {
+ v.insert("closed".into(), json!(p.closed));
+ v.insert("vertex_count".into(), json!(p.vertices.len()));
+ let len: f64 = p
+ .vertices
+ .windows(2)
+ .map(|w| (w[1].x - w[0].x).hypot(w[1].y - w[0].y))
+ .sum();
+ v.insert("length_plan".into(), json!(rounder.derived(len)));
+ }
+ Entity::Spline(s) => {
+ v.insert("control_points".into(), json!(s.control_points.len()));
+ v.insert("fit_points".into(), json!(s.fit_points.len()));
+ confidence = "estimated";
+ why = Some("spline length is not evaluated");
+ }
+ Entity::Point(p) => {
+ v.insert("at".into(), rounder.pt3(p.position));
+ }
+ Entity::Solid(s) | Entity::Trace(s) => {
+ let plane = geom::plane_to_world(s.extrusion, s.elevation);
+ v.insert(
+ "corners".into(),
+ json!([
+ rounder.pt2(plane.apply(s.corner1)),
+ rounder.pt2(plane.apply(s.corner2)),
+ rounder.pt2(plane.apply(s.corner3)),
+ rounder.pt2(plane.apply(s.corner4))
+ ]),
+ );
+ }
+ Entity::Hatch(h) => {
+ v.insert("paths".into(), json!(h.boundary_paths.len()));
+ v.insert("solid_fill".into(), json!(h.solid_fill));
+ confidence = "estimated";
+ }
+ Entity::Leader(l) => {
+ v.insert("vertex_count".into(), json!(l.vertices.len()));
+ v.insert("arrowhead".into(), json!(l.has_arrowhead));
+ }
+ Entity::Ray(r) | Entity::XLine(r) => {
+ v.insert("point".into(), rounder.pt3(r.point));
+ v.insert("vector".into(), rounder.pt3(r.vector));
+ }
+ _ => {
+ confidence = "estimated";
+ }
+ }
+ let (confidence, capped) = capped_confidence(confidence, e.common().confidence);
+ v.insert("unit".into(), json!(unit));
+ v.insert("confidence".into(), json!(confidence));
+ if let Some(w) = capped.or(why) {
+ v.insert("why".into(), json!(w));
+ }
+ v.insert("bbox".into(), rounder.rect(&bbox));
+ geometry.push(Record { id, bbox, value: v });
+ }
+ geometry.sort_by_cached_key(|r| id_key(&r.id));
+ regions.sort_by_cached_key(|r| id_key(&r.id));
+ label_regions(&outlines, texts, &mut regions);
+ (geometry, regions)
+}
+
+/// Gives every region the ids of the texts whose anchor lies inside it and
+/// in no smaller region.
+fn label_regions(
+ outlines: &BTreeMap>,
+ texts: &[PlacedText],
+ regions: &mut [Record],
+) {
+ let mut labels: BTreeMap> = BTreeMap::new();
+ for t in texts {
+ let mut best: Option<(f64, &str)> = None;
+ for r in regions.iter() {
+ if !r.bbox.intersects(&Rect::at(t.anchor.x, t.anchor.y)) {
+ continue;
+ }
+ let Some(vertices) = outlines.get(&r.id) else {
+ continue;
+ };
+ if geom::point_in_polygon(t.anchor, vertices) {
+ let area = r
+ .value
+ .get("area")
+ .and_then(Value::as_f64)
+ .unwrap_or(f64::INFINITY);
+ if best.is_none_or(|(a, _)| area < a) {
+ best = Some((area, r.id.as_str()));
+ }
+ }
+ }
+ if let Some((_, id)) = best {
+ labels.entry(id.to_string()).or_default().push(t.id.clone());
+ }
+ }
+ for r in regions.iter_mut() {
+ let ids = labels.remove(&r.id).unwrap_or_default();
+ r.value.insert("labels".into(), json!(ids));
+ }
+}
+
+/// The block instance records -- every shown INSERT that measured an
+/// extent, with its attributes -- and the instances of each block, by name.
+fn block_records(
+ shown: &[(&Part, &Entity)],
+ extent_of: &BTreeMap,
+ rounder: &Rounder,
+) -> (Vec, BTreeMap>) {
+ let mut instances: BTreeMap> = BTreeMap::new();
+ let mut out = Vec::new();
+ for (part, e) in shown {
+ let Entity::Insert(i) = e else { continue };
+ let Some(bbox) = extent_of.get(&part.id).copied() else {
+ continue;
+ };
+ let id = part.id.value().to_string();
+ instances
+ .entry(i.block_name.name().to_string())
+ .or_default()
+ .push(id.clone());
+ let attribs: Map = i
+ .attribs
+ .iter()
+ .filter(|a| !a.tag.is_empty())
+ .map(|a| (a.tag.clone(), json!(decode_text(&a.text).plain)))
+ .collect();
+ // Where the block's origin lands: the placement the model computes,
+ // its own plane included.
+ let placement = uncad_model::Affine2::from_insert(i);
+ let mut v = Map::new();
+ v.insert("id".into(), json!(id));
+ v.insert("handle".into(), handle_of(&i.common));
+ v.insert("block".into(), json!(i.block_name.name()));
+ v.insert("layer".into(), json!(layer_name(&i.common.layer)));
+ v.insert(
+ "at".into(),
+ rounder.pt2(uncad_model::Point2D {
+ x: placement.e,
+ y: placement.f,
+ }),
+ );
+ v.insert(
+ "rotation_deg".into(),
+ json!(rounder.derived(i.rotation.to_degrees())),
+ );
+ v.insert("scale".into(), json!([i.scale.x, i.scale.y, i.scale.z]));
+ v.insert("mirrored".into(), json!(placement.determinant() < 0.0));
+ v.insert("attribs".into(), Value::Object(attribs));
+ v.insert("bbox".into(), rounder.rect(&bbox));
+ out.push(Record { id, bbox, value: v });
+ }
+ out.sort_by_cached_key(|r| id_key(&r.id));
+ (out, instances)
+}
+
+/// `drawing.json`: the header, the units, the layers with their state and
+/// how many top-level entities each holds, the block definitions with their
+/// instances, and the entity counts.
+fn drawing_json(
+ db: &CadDatabase,
+ header: Option<&uncad::Header>,
+ parts: &[Part],
+ top: &BTreeMap,
+ instances: &BTreeMap>,
+ units: &Value,
+) -> Result {
+ let mut layer_counts: BTreeMap<&str, usize> = BTreeMap::new();
+ let mut type_counts: BTreeMap<&str, usize> = BTreeMap::new();
+ for p in parts {
+ *type_counts.entry(p.type_name.as_str()).or_default() += 1;
+ if let Some(e) = top.get(&p.id) {
+ *layer_counts
+ .entry(layer_name(&e.common().layer))
+ .or_default() += 1;
+ }
+ }
+ let layers: Vec = db
+ .tables
+ .layers
+ .values()
+ .map(|l| {
+ json!({
+ "name": l.name,
+ "color_index": l.color_index,
+ "on": !l.off,
+ "frozen": l.frozen,
+ "locked": l.locked,
+ "plot": l.plot,
+ // Hundredths of a millimetre in the model; -3 is "the
+ // default", which is no width of its own.
+ "lineweight_mm": l.lineweight.filter(|w| *w >= 0).map(|w| f64::from(w) / 100.0),
+ "linetype": l.linetype.name(),
+ "entity_count": layer_counts.get(l.name.as_str()).copied().unwrap_or(0),
+ })
+ })
+ .collect();
+ let definitions: Vec = db
+ .tables
+ .block_records
+ .values()
+ .filter(|b| {
+ let upper = b.name.to_uppercase();
+ !upper.starts_with("*MODEL_SPACE") && !upper.starts_with("*PAPER_SPACE")
+ })
+ .map(|b| {
+ let mut by_layer: BTreeMap<&str, usize> = BTreeMap::new();
+ let mut tags: BTreeSet<&str> = BTreeSet::new();
+ for e in &b.entities {
+ *by_layer.entry(layer_name(&e.common().layer)).or_default() += 1;
+ if let Entity::Attdef(a) = e {
+ tags.insert(a.tag.as_str());
+ }
+ }
+ let ids = instances.get(&b.name).cloned().unwrap_or_default();
+ json!({
+ "name": b.name,
+ "entity_count": b.entities.len(),
+ "count_by_layer": by_layer,
+ "attrib_tags": tags,
+ "anonymous": b.name.starts_with('*'),
+ "instances": ids.len(),
+ "instance_ids": ids,
+ })
+ })
+ .collect();
+ Ok(json!({
+ "$schema": SCHEMA,
+ "units": units,
+ "header": match header {
+ Some(h) => serde_json::to_value(h)?,
+ None => Value::Null,
+ },
+ "read_diagnostics": db.read_diagnostics.warnings,
+ "layers": layers,
+ "blocks": definitions,
+ "counts": { "model_space": parts.len(), "by_type": type_counts },
+ }))
+}
+
+struct ManifestInput<'a, 'w> {
+ header: Option<&'a uncad::Header>,
+ options: &'a ExportOptions,
+ writer: &'a mut Writer<'w>,
+ crop: &'a CropReport,
+ svg_origin: Option<[f64; 2]>,
+ overview: &'a ImageInfo,
+ frames: &'a [FrameReport],
+ frames_dropped: &'a [Value],
+ frames_dropped_total: usize,
+ rounder: &'a Rounder,
+ counts: &'a Counts,
+ texts: &'a [Record],
+ paper_texts: &'a [Record],
+ sheets: &'a [SheetReport],
+ dims: &'a [Record],
+ regions: &'a [Record],
+ warnings: &'a [String],
+}
+
+fn manifest_json(m: ManifestInput<'_, '_>) -> Value {
+ let profile = m.options.profile;
+ // "exact" is reserved for values the file itself measured: a package of
+ // an R13/R14 drawing (no act_measurement anywhere) carries values this
+ // crate recomputed from the definition points.
+ let dim_source = |want: &str| {
+ m.dims
+ .iter()
+ .any(|r| r.value.get("measurement_source").is_some_and(|s| s == want))
+ };
+ // `areas` says what the region records say, the way `dimension_values`
+ // does.
+ let area_confidence = |want: &str| {
+ m.regions
+ .iter()
+ .filter(|r| r.value.get("confidence").is_some_and(|c| c == want))
+ .count()
+ };
+ let (areas_exact, areas_estimated, areas_unavailable) = (
+ area_confidence("exact"),
+ area_confidence("estimated"),
+ area_confidence("unavailable"),
+ );
+ let measured_total = m
+ .texts
+ .iter()
+ .filter(|r| {
+ r.value
+ .get("bbox_confidence")
+ .is_some_and(|c| c == "measured")
+ })
+ .count();
+ let fonts_name = if m.options.fonts == fonts::bundled() {
+ "bundled"
+ } else if m.options.fonts == Fonts::System {
+ "system"
+ } else {
+ "custom"
+ };
+ let capabilities = json!({
+ "dimension_values": if m.dims.is_empty() { "none" } else if dim_source("act_measurement") { "exact" } else if dim_source("from_points") { "computed" } else { "text_only" },
+ "areas": if m.regions.is_empty() { "none" } else if areas_exact == m.regions.len() { "exact" } else if areas_exact > 0 { "mixed" } else if areas_unavailable > 0 && areas_estimated == 0 { "unavailable" } else { "estimated" },
+ "areas_by_confidence": { "exact": areas_exact, "estimated": areas_estimated, "unavailable": areas_unavailable },
+ "text_boxes": if m.texts.is_empty() { "none" } else if measured_total == m.texts.len() { "measured" } else if measured_total == 0 { "estimated" } else { "mixed" },
+ "fonts": fonts_name,
+ "paper_layouts": if m.sheets.is_empty() { "none" } else { "composited" },
+ "paper_text": if m.sheets.is_empty() { "none" } else if m.paper_texts.is_empty() { "empty" } else { "indexed" },
+ "frames": m.frames.len(),
+ });
+ let legend = json!({
+ "confidence": {
+ "carried_by": ["geometry", "region", "dimension"],
+ "exact": "computed from the file's own coordinates",
+ "stored": "the value the file stores (a dimension's act_measurement)",
+ "estimated": "approximated, or a check that was skipped; `why` says which",
+ "unavailable": "no meaningful value; `why` says why, and any number beside it is not to be used",
+ "none": "there was nothing to compute (used by capabilities, not by records)",
+ },
+ "text_records": "texts carry `bbox_confidence` (`measured` from the glyph outlines as the renderer laid them out, or `estimated` at 0.6 em per character) and no `confidence`; block instances carry neither. `space` is `model` or `paper`; a paper text names its `sheet` and is measured in that layout's paper units.",
+ "ids": "a record's `id` is the drawing's entity reference ID in decimal (a path of them for a text inside a block); its `handle` is the file's own handle for the entity, in hexadecimal, when it came from a file",
+ "measurement_source": ["act_measurement", "from_points", "none"],
+ "display_source": ["user_text", "cached_block", "formatted", "suppressed", "none"],
+ "region_labels": "`labels` holds the ids of the text records whose anchor falls inside the region",
+ "px_boxes": "`px` maps an image id to [x0, y0, x1, y1] in that image's pixels, y down, clipped to the image; the record's full extent is its world `bbox`, and the rest of it is on the other images it lists",
+ "tile_sidecar": "`records` holds positional rows described by the sidecar's own `columns`; `counts` is the true number of records of each kind on the tile, which `records_truncated` does not affect, and `geometry_by_kind` summarises the geometry whether or not its rows fit",
+ "shard_lookup": "manifest.shard_index resolves a record id to its file: the entry whose [first_key, last_key] contains int(id.split('/')[0]). `first_id`/`last_id` are the same bounds as strings and do not compare as numbers",
+ "legibility": "`target_met` is whether every text height class reaches `target_px` in the deepest image of that frame; `pyramid_complete` is whether the tile budget let the pyramid reach the depth the text asked for",
+ });
+ // The tile and overlap numbers come from the profile in use, not from
+ // the prose: claude-hires writes 1932 px tiles with 392 px of overlap.
+ let guidance = format!(
+ "Read manifest.json first. Numbers (lengths, areas, dimension values, text) come from the JSON records, never from pixels; `legend` says what `confidence` and the package's other vocabularies mean and which records carry them. To find something: look its text up in strings.json (normalised: trimmed, lower-case, single spaces), resolve the id through shard_index -- the entry whose [first_key, last_key] contains int(id.split('/')[0]), since the id does not say its kind and one id can be in two -- then open the tile(s) in its `tiles` list; every tile's .json sidecar lists what is on it with pixel boxes, its own `columns` legend and a `counts` object that stays exact when rows are cut. Pixel boxes are clipped to the image they are quoted in; the record's world `bbox` is its full extent. overview.png shows the whole crop; each frame in `frames` (f0 the main drawing, f1.. details drawn beside it) has its own overview and tiles z1..zN, {} px with {} px overlap (2x zooms), row 0 at the top; a group too small to be framed is in `frames_dropped` and its records carry `tiles: []`. The drawing's title and title block are paper-space text: those records carry `space: \"paper\"` and a `sheet`, and their pixel box is on that sheet's image, not on a tile. report.json lists what was left out and why.",
+ profile.tile, profile.overlap
+ );
+ m.writer.files.push(WrittenFile {
+ path: "manifest.json".into(),
+ bytes: None,
+ kind: "manifest".into(),
+ });
+ m.writer.files.push(WrittenFile {
+ path: "README.txt".into(),
+ bytes: None,
+ kind: "readme".into(),
+ });
+ json!({
+ "$schema": SCHEMA,
+ "generator": { "name": "uncad-export", "version": env!("CARGO_PKG_VERSION") },
+ "profile": profile.name,
+ "source": {
+ "name": m.options.source_name,
+ "format": m.header.map(|h| h.format),
+ "acadver": m.header.and_then(|h| h.acadver.clone()),
+ "version": m.header.and_then(|h| h.version.clone()),
+ "codepage": m.header.and_then(|h| h.codepage_name.clone()),
+ },
+ "units": m.writer.units,
+ "crop": m.crop,
+ // drawing.svg's user units are world minus this (null without it).
+ "svg_origin": m.svg_origin,
+ "overview": m.overview,
+ "frames": m.frames,
+ "frames_dropped": m.frames_dropped,
+ "frames_dropped_total": m.frames_dropped_total,
+ "sheets": m.sheets.iter().map(|s| json!({"name": s.name, "tab_order": s.tab_order, "png": s.overview.png, "px": s.overview.px, "rect": m.rounder.rect(&s.rect), "units": s.units, "viewports": s.viewports.len(), "texts": m.paper_texts.iter().filter(|r| r.value.get("sheet").is_some_and(|n| n == s.name.as_str())).count()})).collect::>(),
+ "legibility": { "target_px": m.options.target_text_px, "per_frame": m.frames.iter().map(|f| json!({"frame": f.id, "z_max": f.z_max, "target_met": f.height_classes.iter().all(|c| c.legible), "pyramid_complete": f.reached, "height_classes": f.height_classes})).collect::>() },
+ "counts": m.counts,
+ "capabilities": capabilities,
+ "legend": legend,
+ "guidance": guidance,
+ "files": m.writer.files,
+ "shard_index": m.writer.shard_index,
+ "warnings": m.warnings,
+ })
+}
+
+fn readme(options: &ExportOptions) -> String {
+ format!(
+ "uncad package ({SCHEMA})\n\nReading order:\n 1. manifest.json what is here, the crop, the images and their affines; `legend` explains the record vocabularies, `guidance` how to look something up\n 2. strings.json find a text or a number, get record ids; shard_index turns an id into a file (compare int(id.split('/')[0]) against first_key/last_key, not the strings)\n 3. texts.json / dimensions.json / geometry.json / regions.json / blocks.json the records (sharded above {} KB, see shard_index); blocks.json holds the INSERT instances, drawing.json the block definitions\n 4. overview.png the whole drawing; frames/f*/overview.png and frames/f*/tiles/z*/ zoomed tiles with .json sidecars; tiles.json lists every tile, written or empty with a reason, with its size and sha256\n 5. sheets.json paper layouts: sheet size, viewports with their scale and model window, and how a model point maps onto the sheet; sheets//overview.png. The title and title block are paper-space texts in texts.json (`space: \"paper\"`, with a `sheet` and that sheet's pixel box)\n 6. report.json what was left out and why\n\nAll other records are model space. drawing.json holds the header, units, layer states and block definitions; entities.json and drawing.svg (when present) are tool inputs, not for reading.\n",
+ options.shard_kb
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn strings_are_normalized_for_lookup() {
+ assert_eq!(normalize_string(" Room 101 \n"), "room 101");
+ assert_eq!(normalize_string("32.5㎡"), "32.5m2");
+ assert_eq!(normalize_string("½\" Ø"), "1/2\" ø");
+ assert_eq!(normalize_string("A1"), "a1");
+ assert_eq!(compact_string(" 32.5 m 2 "), "32.5m2");
+ assert_eq!(area_unit("mm"), "mm2");
+ assert_eq!(area_unit("du"), "du2");
+ }
+
+ #[test]
+ fn profiles_by_name() {
+ assert_eq!(Profile::by_name("claude"), Some(Profile::CLAUDE));
+ assert_eq!(
+ Profile::by_name("claude-hires"),
+ Some(Profile::CLAUDE_HIRES)
+ );
+ assert_eq!(
+ Profile::by_name("openai-patch"),
+ Some(Profile::OPENAI_PATCH)
+ );
+ assert_eq!(Profile::by_name("gemini"), None);
+ }
+
+ #[test]
+ fn an_overview_uses_its_budget_and_a_point_gets_a_window() {
+ // A 1000 x 500 drawing on Claude's budget: 56 patches wide is the
+ // edge limit, and 28 of them tall keeps 1568 patches.
+ let fit = fit_overview(&Rect::new(0.0, 0.0, 1000.0, 500.0), &Profile::CLAUDE, None);
+ assert_eq!(fit.width % 28, 0);
+ assert_eq!(fit.height % 28, 0);
+ assert!(fit.width <= 1568 && (fit.width / 28) * (fit.height / 28) <= 1568);
+ assert!((fit.rect.width() * fit.ppu - f64::from(fit.width)).abs() < 1e-6);
+ // A single point: a ten-unit window, not a 1e-11-unit one.
+ let fit = fit_overview(&Rect::at(3.0, 3.0), &Profile::CLAUDE, None);
+ assert!((10.0..=12.0).contains(&fit.rect.width()), "{:?}", fit.rect);
+ assert!(fit.ppu.is_finite() && fit.ppu < 1000.0);
+ }
+}
diff --git a/crates/uncad-export/src/package/output.rs b/crates/uncad-export/src/package/output.rs
new file mode 100644
index 0000000..b87e2a2
--- /dev/null
+++ b/crates/uncad-export/src/package/output.rs
@@ -0,0 +1,376 @@
+//! Writing the package: files and their listing, record shards and their
+//! index, the 8-bit RGB images, the tile hashes, and clearing what a
+//! previous package in the same directory left.
+
+use std::path::Path;
+
+use serde::Serialize;
+use serde_json::{json, Value};
+
+use super::records::{id_key, Record};
+use super::{ExportError, SCHEMA};
+
+/// One file the package holds.
+#[derive(Debug, Clone, PartialEq, Serialize)]
+pub struct WrittenFile {
+ /// Relative to the package directory, `/`-separated.
+ pub path: String,
+ /// `None` for the three files written after the listing was made
+ /// (`manifest.json`, `README.txt`, `report.json`).
+ pub bytes: Option,
+ pub kind: String,
+}
+
+pub(crate) struct Writer<'a> {
+ pub(crate) dir: &'a Path,
+ pub(crate) files: Vec,
+ pub(crate) shard_index: Vec,
+ pub(crate) units: Value,
+ pub(crate) shard_kb: usize,
+}
+
+impl Writer<'_> {
+ pub(crate) fn write_bytes(
+ &mut self,
+ rel: &str,
+ bytes: &[u8],
+ kind: &str,
+ ) -> Result<(), ExportError> {
+ let path = self.dir.join(rel);
+ if let Some(parent) = path.parent() {
+ std::fs::create_dir_all(parent).map_err(|source| ExportError::Io {
+ path: parent.to_path_buf(),
+ source,
+ })?;
+ }
+ std::fs::write(&path, bytes).map_err(|source| ExportError::Io { path, source })?;
+ self.files.push(WrittenFile {
+ path: rel.to_string(),
+ bytes: Some(bytes.len() as u64),
+ kind: kind.to_string(),
+ });
+ Ok(())
+ }
+
+ pub(crate) fn write_json(
+ &mut self,
+ rel: &str,
+ value: &Value,
+ kind: &str,
+ ) -> Result<(), ExportError> {
+ let text = serde_json::to_string_pretty(value)?;
+ self.write_bytes(rel, text.as_bytes(), kind)
+ }
+
+ /// [`Writer::write_json`] without the indentation, for a file whose own
+ /// size is the thing being kept: a sidecar drops record rows until its
+ /// compact form fits its cap, so pretty-printing it afterwards would put
+ /// a file three times the measured size on disk. Record shards are
+ /// written compact for the same reason.
+ pub(crate) fn write_json_compact(
+ &mut self,
+ rel: &str,
+ value: &Value,
+ kind: &str,
+ ) -> Result<(), ExportError> {
+ let text = serde_json::to_string(value)?;
+ self.write_bytes(rel, text.as_bytes(), kind)
+ }
+
+ /// Writes `records` (already in id order) as `name.json`, or as
+ /// `name.NNN.json` shards above `shard_kb`, and indexes them.
+ pub(crate) fn write_records(
+ &mut self,
+ name: &str,
+ kind: &str,
+ records: &[Record],
+ ) -> Result<(), ExportError> {
+ let limit = self.shard_kb.max(1) * 1024;
+ let mut shards: Vec> = vec![Vec::new()];
+ let mut bytes = 0usize;
+ for r in records {
+ let size = serde_json::to_string(&r.value)?.len() + 8;
+ if bytes + size > limit && !shards.last().is_none_or(Vec::is_empty) {
+ shards.push(Vec::new());
+ bytes = 0;
+ }
+ shards.last_mut().expect("one shard").push(r);
+ bytes += size;
+ }
+ let single = shards.len() == 1;
+ for (n, shard) in shards.iter().enumerate() {
+ let file = if single {
+ format!("{name}.json")
+ } else {
+ format!("{name}.{:03}.json", n + 1)
+ };
+ let value = json!({
+ "$schema": SCHEMA,
+ "kind": kind,
+ "units": self.units,
+ "count": shard.len(),
+ "records": shard.iter().map(|r| Value::Object(r.value.clone())).collect::>(),
+ });
+ let text = serde_json::to_string(&value)?;
+ self.write_bytes(&file, text.as_bytes(), kind)?;
+ // `first_id`/`last_id` alone are no index: ids are paths of
+ // numbers written as strings, ordered by their numbers, so the
+ // string comparison a consumer would reach for picks the wrong
+ // shard ("10" sorts before "9"). `first_key`/`last_key` publish
+ // the order itself: the id's first number.
+ let key = |r: Option<&&Record>| r.map(|r| id_key(&r.id)[0]);
+ self.shard_index.push(json!({
+ "file": file,
+ "kind": kind,
+ "first_id": shard.first().map(|r| r.id.as_str()),
+ "last_id": shard.last().map(|r| r.id.as_str()),
+ "first_key": key(shard.first()),
+ "last_key": key(shard.last()),
+ "count": shard.len(),
+ "bytes": text.len(),
+ }));
+ }
+ Ok(())
+ }
+}
+
+/// Removes what a previous uncad package in `dir` left behind, so a second
+/// export with other options does not leave stale shards, tile PNGs and
+/// sidecars beside the new ones: they look valid (same schema, same record
+/// ids) and a consumer that walks the tree would mix two exports.
+///
+/// Only the files the previous `manifest.json` lists are removed, and only
+/// when it is an uncad manifest: a directory holding anything else is left
+/// alone, and a listed path that is not a plain relative path inside `dir`
+/// is ignored. Directories under `frames/` and `sheets/` go when they are
+/// left empty. Every failure is ignored -- the write that follows reports
+/// what actually matters.
+pub(crate) fn clear_previous_package(dir: &Path) {
+ let Ok(text) = std::fs::read_to_string(dir.join("manifest.json")) else {
+ return;
+ };
+ let Ok(manifest) = serde_json::from_str::(&text) else {
+ return;
+ };
+ let ours = manifest
+ .get("$schema")
+ .and_then(Value::as_str)
+ .is_some_and(|s| s.starts_with("uncad-package/"));
+ if !ours {
+ return;
+ }
+ let files = manifest.get("files").and_then(Value::as_array);
+ for file in files.into_iter().flatten() {
+ let Some(rel) = file.get("path").and_then(Value::as_str) else {
+ continue;
+ };
+ let inside = !rel.is_empty()
+ && Path::new(rel)
+ .components()
+ .all(|c| matches!(c, std::path::Component::Normal(_)));
+ if inside {
+ let _ = std::fs::remove_file(dir.join(rel));
+ }
+ }
+ for sub in ["frames", "sheets"] {
+ remove_empty_dirs(&dir.join(sub));
+ }
+}
+
+/// Removes `dir` and every directory under it that is empty once its own
+/// empty children are gone; a directory still holding a file stays (with
+/// everything above it).
+fn remove_empty_dirs(dir: &Path) {
+ let Ok(entries) = std::fs::read_dir(dir) else {
+ return;
+ };
+ for entry in entries.flatten() {
+ if entry.file_type().is_ok_and(|t| t.is_dir()) {
+ remove_empty_dirs(&entry.path());
+ }
+ }
+ // Fails, harmlessly, when anything is left in it.
+ let _ = std::fs::remove_dir(dir);
+}
+
+/// The renderer's PNG (RGBA, on an opaque white background) as 8-bit RGB:
+/// a quarter fewer bytes to decode for an image that has no transparency
+/// to carry, and the form the package has always written.
+pub(crate) fn to_rgb(rgba_png: &[u8]) -> Result, ExportError> {
+ let encode = |e: &dyn std::fmt::Display| ExportError::Encode(e.to_string());
+ let mut decoder = png::Decoder::new(std::io::Cursor::new(rgba_png));
+ decoder.set_transformations(png::Transformations::EXPAND);
+ let mut reader = decoder.read_info().map_err(|e| encode(&e))?;
+ let size = reader
+ .output_buffer_size()
+ .ok_or_else(|| ExportError::Encode("the image is too large to decode".into()))?;
+ let mut buf = vec![0; size];
+ let info = reader.next_frame(&mut buf).map_err(|e| encode(&e))?;
+ let samples = info.color_type.samples();
+ let data = &buf[..info.buffer_size()];
+ let rgb: Vec = match samples {
+ 4 => data
+ .as_chunks::<4>()
+ .0
+ .iter()
+ .flat_map(|px| {
+ // Straight colour over white: the renderer fills the
+ // background, so alpha is 255 and this is the identity.
+ let a = u32::from(px[3]);
+ let over = |c: u8| ((u32::from(c) * a + 255 * (255 - a) + 127) / 255) as u8;
+ [over(px[0]), over(px[1]), over(px[2])]
+ })
+ .collect(),
+ 3 => data.to_vec(),
+ _ => {
+ return Err(ExportError::Encode(format!(
+ "unexpected PNG colour type {:?}",
+ info.color_type
+ )))
+ }
+ };
+ let mut out = Vec::new();
+ {
+ let mut encoder = png::Encoder::new(&mut out, info.width, info.height);
+ encoder.set_color(png::ColorType::Rgb);
+ encoder.set_depth(png::BitDepth::Eight);
+ let mut writer = encoder.write_header().map_err(|e| encode(&e))?;
+ writer.write_image_data(&rgb).map_err(|e| encode(&e))?;
+ writer.finish().map_err(|e| encode(&e))?;
+ }
+ Ok(out)
+}
+
+/// SHA-256 of `data` as lower-case hex (FIPS 180-4), for the per-tile
+/// hashes `tiles.json` publishes: a package verifier can check a tile PNG
+/// against the manifest without re-running the export. Written out here
+/// rather than pulled in: 40 lines of a fully specified function, pinned by
+/// the standard test vectors below.
+pub(crate) fn sha256_hex(data: &[u8]) -> String {
+ const K: [u32; 64] = [
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
+ 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
+ 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
+ 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
+ 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
+ 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
+ 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
+ 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
+ 0xc67178f2,
+ ];
+ let mut h: [u32; 8] = [
+ 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
+ 0x5be0cd19,
+ ];
+ let bit_len = (data.len() as u64).wrapping_mul(8);
+ let mut padded = Vec::with_capacity(data.len() + 72);
+ padded.extend_from_slice(data);
+ padded.push(0x80);
+ while padded.len() % 64 != 56 {
+ padded.push(0);
+ }
+ padded.extend_from_slice(&bit_len.to_be_bytes());
+ let (blocks, _) = padded.as_chunks::<64>();
+ for block in blocks {
+ let mut w = [0u32; 64];
+ let (words, _) = block.as_chunks::<4>();
+ for (word, bytes) in w.iter_mut().zip(words) {
+ *word = u32::from_be_bytes(*bytes);
+ }
+ for i in 16..64 {
+ let (a, b) = (w[i - 15], w[i - 2]);
+ let s0 = a.rotate_right(7) ^ a.rotate_right(18) ^ (a >> 3);
+ let s1 = b.rotate_right(17) ^ b.rotate_right(19) ^ (b >> 10);
+ w[i] = w[i - 16]
+ .wrapping_add(s0)
+ .wrapping_add(w[i - 7])
+ .wrapping_add(s1);
+ }
+ let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut hh] = h;
+ for (k, wi) in K.iter().zip(w.iter()) {
+ let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
+ let ch = (e & f) ^ (!e & g);
+ let t1 = hh
+ .wrapping_add(s1)
+ .wrapping_add(ch)
+ .wrapping_add(*k)
+ .wrapping_add(*wi);
+ let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
+ let maj = (a & b) ^ (a & c) ^ (b & c);
+ let t2 = s0.wrapping_add(maj);
+ hh = g;
+ g = f;
+ f = e;
+ e = d.wrapping_add(t1);
+ d = c;
+ c = b;
+ b = a;
+ a = t1.wrapping_add(t2);
+ }
+ for (x, y) in h.iter_mut().zip([a, b, c, d, e, f, g, hh]) {
+ *x = x.wrapping_add(y);
+ }
+ }
+ h.iter().map(|v| format!("{v:08x}")).collect()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn sha256_matches_the_published_vectors() {
+ // FIPS 180-4's own examples, plus the one-block/two-block boundary
+ // (55, 56 and 64 bytes, where the padding runs into a second
+ // block).
+ assert_eq!(
+ sha256_hex(b""),
+ "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
+ );
+ assert_eq!(
+ sha256_hex(b"abc"),
+ "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
+ );
+ assert_eq!(
+ sha256_hex(b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"),
+ "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"
+ );
+ assert_eq!(
+ sha256_hex(&[b'a'; 1000]),
+ "41edece42d63e8d9bf515a9ba6932e1c20cbc9f5a5d134645adb5db1b9737ea3"
+ );
+ for len in [55, 56, 63, 64, 65] {
+ let hash = sha256_hex(&vec![0u8; len]);
+ assert_eq!(hash.len(), 64, "{len} bytes");
+ assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
+ }
+ assert_eq!(
+ sha256_hex(&[b'a'; 56]),
+ "b35439a4ac6f0948b6d6f9e3c6af0f5f590ce20f1bde7090ef7970686ec6738a"
+ );
+ }
+
+ #[test]
+ fn an_rgba_png_on_white_becomes_the_same_picture_in_rgb() {
+ // A 2 x 1 RGBA image: one opaque red pixel, one half-transparent
+ // black one, which over white is mid-grey.
+ let mut rgba = Vec::new();
+ {
+ let mut encoder = png::Encoder::new(&mut rgba, 2, 1);
+ encoder.set_color(png::ColorType::Rgba);
+ encoder.set_depth(png::BitDepth::Eight);
+ let mut writer = encoder.write_header().unwrap();
+ writer
+ .write_image_data(&[255, 0, 0, 255, 0, 0, 0, 128])
+ .unwrap();
+ }
+ let rgb = to_rgb(&rgba).expect("re-encodes");
+ let decoder = png::Decoder::new(std::io::Cursor::new(&rgb));
+ let mut reader = decoder.read_info().unwrap();
+ let mut buf = vec![0; reader.output_buffer_size().unwrap()];
+ let info = reader.next_frame(&mut buf).unwrap();
+ assert_eq!(info.color_type, png::ColorType::Rgb);
+ assert_eq!(&buf[..6], &[255, 0, 0, 127, 127, 127]);
+ }
+}
diff --git a/crates/uncad-export/src/package/records.rs b/crates/uncad-export/src/package/records.rs
new file mode 100644
index 0000000..85b5146
--- /dev/null
+++ b/crates/uncad-export/src/package/records.rs
@@ -0,0 +1,636 @@
+//! The package's records: one per text, dimension, geometric entity,
+//! closed region and block instance, each with its exact numbers, its world
+//! box and -- filled in by the caller -- the images it is on.
+//!
+//! A record's id is the model's reference ID ([`EntityId`]) as a decimal
+//! string, or the path of IDs `/` for a text drawn inside a
+//! block reference: the same path the renderer names that text's ``
+//! element with (`t.`). The file's own handle, where the entity
+//! came from a file, is the record's `handle`.
+
+use std::collections::{BTreeMap, BTreeSet};
+
+use iron_render_cad::TextBox;
+use serde_json::{json, Map, Value};
+use uncad_model::model::{
+ AttribEntity, Confidence, Entity, EntityCommon, EntityId, HorizontalJustification, Point2D,
+ Point3D, Ref, VerticalJustification,
+};
+use uncad_model::tables::BlockRecord;
+use uncad_model::{Affine2, Tables};
+
+use crate::dimension::tolerance_text_height;
+use crate::frame::Rect;
+use crate::geom::plane_to_world;
+use crate::text::{decode_mtext, decode_text};
+
+/// One entity's record before its images are known.
+#[derive(Clone)]
+pub(crate) struct Record {
+ pub(crate) id: String,
+ pub(crate) bbox: Rect,
+ pub(crate) value: Map,
+}
+
+/// A record id: the path's IDs, outermost first, joined by `/`.
+pub(crate) fn record_id(path: &[EntityId]) -> String {
+ let mut id = String::new();
+ for (i, e) in path.iter().enumerate() {
+ if i > 0 {
+ id.push('/');
+ }
+ id.push_str(&e.value().to_string());
+ }
+ id
+}
+
+/// The order records are written in, and what a shard's `first_key` and
+/// `last_key` publish: the id's IDs as numbers. A malformed segment sorts
+/// last.
+pub(crate) fn id_key(id: &str) -> Vec {
+ id.split('/')
+ .map(|s| s.parse::().unwrap_or(u64::MAX))
+ .collect()
+}
+
+/// The file's handle for an entity, when it came from a file.
+pub(crate) fn handle_of(common: &EntityCommon) -> Value {
+ match &common.source_handle {
+ Ref::Resolved(h) => json!(h),
+ Ref::Absent | Ref::Unresolved(_) => Value::Null,
+ }
+}
+
+/// A layer reference as a record names it: the name it resolved to; the
+/// name or handle the file wrote when nothing answers to it; `""` when the
+/// file names none.
+pub(crate) fn layer_name(layer: &Ref) -> &str {
+ match layer {
+ Ref::Resolved(name) | Ref::Unresolved(name) => name,
+ Ref::Absent => "",
+ }
+}
+
+// ------------------------------------------------------------- rounding
+
+pub(crate) fn round_to(v: f64, decimals: u16) -> f64 {
+ let f = 10f64.powi(i32::from(decimals));
+ let r = (v * f).round() / f;
+ if r == 0.0 {
+ 0.0
+ } else {
+ r
+ }
+}
+
+pub(crate) struct Rounder {
+ pub(crate) coords: u16,
+ pub(crate) derived: u16,
+}
+
+impl Rounder {
+ pub(crate) fn coord(&self, v: f64) -> f64 {
+ round_to(v, self.coords)
+ }
+
+ pub(crate) fn derived(&self, v: f64) -> f64 {
+ round_to(v, self.derived)
+ }
+
+ pub(crate) fn pt2(&self, p: Point2D) -> Value {
+ json!([self.coord(p.x), self.coord(p.y)])
+ }
+
+ pub(crate) fn pt3(&self, p: Point3D) -> Value {
+ json!([self.coord(p.x), self.coord(p.y)])
+ }
+
+ pub(crate) fn rect(&self, r: &Rect) -> Value {
+ json!([
+ self.coord(r.min_x),
+ self.coord(r.min_y),
+ self.coord(r.max_x),
+ self.coord(r.max_y)
+ ])
+ }
+}
+
+/// The decimals a world coordinate is written with: `$LUPREC` (clamped to
+/// 3..=12), but never fewer than the package's own scale needs.
+///
+/// `$LUPREC` says how precisely the drawing's units are *displayed*; it
+/// says nothing about how far the package zooms into them. A drawing a few
+/// thousandths of a unit across is drawn at hundreds of thousands of pixels
+/// per unit, where LUPREC's three or four decimals put every tile rectangle
+/// of a frame on the same numbers: the `world` a consumer reads is then a
+/// different rectangle from the one the tile shows, and the affine beside
+/// it maps to the wrong pixels.
+///
+/// The floor is the scale's. `deepest_ppu` is the finest scale the package
+/// may reach -- the overview's, doubled once per zoom level -- and the
+/// coordinate carries three decimals beyond one pixel there, so one unit in
+/// the last place is a thousandth of a pixel.
+pub(crate) fn coord_decimals(luprec: u16, deepest_ppu: f64) -> u16 {
+ let from_scale = if deepest_ppu.is_finite() && deepest_ppu > 0.0 {
+ (deepest_ppu.log10().ceil() + 3.0).clamp(3.0, 12.0) as u16
+ } else {
+ 3
+ };
+ luprec.clamp(3, 12).max(from_scale)
+}
+
+// ----------------------------------------------------------- visibility
+
+/// The layer an entity is drawn on: its own, except that an entity on
+/// layer 0 inside a block reference takes the layer of the reference that
+/// places it -- the rule the renderer draws by.
+pub(crate) fn effective_layer<'a>(layer: &'a str, reference_layer: Option<&'a str>) -> &'a str {
+ match reference_layer {
+ Some(reference) if layer == "0" => reference,
+ _ => layer,
+ }
+}
+
+/// Whether the drawing hides an entity with `common` drawn under
+/// `reference_layer` -- the renderer's rule: its own invisible flag (or an
+/// attribute's), AutoCAD's `DEFPOINTS` layer, or a layer that is off,
+/// frozen or stated not to plot. A layer the tables do not hold is shown.
+pub(crate) fn is_hidden(
+ common: &EntityCommon,
+ invisible_attribute: bool,
+ tables: &Tables,
+ reference_layer: Option<&str>,
+) -> bool {
+ if common.invisible || invisible_attribute {
+ return true;
+ }
+ let layer = effective_layer(common.layer.name(), reference_layer);
+ if layer.eq_ignore_ascii_case("DEFPOINTS") {
+ return true;
+ }
+ tables
+ .layers
+ .get(layer)
+ .is_some_and(|l| l.off || l.frozen || l.plot == Some(false))
+}
+
+fn entity_hidden(e: &Entity, tables: &Tables, reference_layer: Option<&str>) -> bool {
+ let invisible_attribute = matches!(e, Entity::Attrib(a) if a.flags.invisible);
+ is_hidden(e.common(), invisible_attribute, tables, reference_layer)
+}
+
+// ---------------------------------------------------------------- texts
+
+/// A text placed in the world (or on a sheet's paper).
+pub(crate) struct PlacedText {
+ pub(crate) id: String,
+ /// The top-level entity whose part draws the text: its path's first ID.
+ pub(crate) part: EntityId,
+ pub(crate) handle: Value,
+ pub(crate) kind: &'static str,
+ pub(crate) layer: String,
+ pub(crate) text: String,
+ pub(crate) raw: String,
+ pub(crate) height: f64,
+ pub(crate) rotation: f64,
+ pub(crate) anchor: Point2D,
+ /// The glyph outlines' box when the fonts laid the text out, the
+ /// renderer's estimate otherwise.
+ pub(crate) bbox: Rect,
+ pub(crate) measured: bool,
+ /// Glyphs the fonts could not shape (drawn as `.notdef` boxes).
+ pub(crate) unshaped: usize,
+ pub(crate) style: String,
+ pub(crate) tag: Option,
+}
+
+/// An entity a text path steps through: a drawing's entity, or an
+/// attribute that hangs off a nested block reference and nothing else.
+#[derive(Clone, Copy)]
+enum Located<'a> {
+ Entity(&'a Entity),
+ NestedAttribute(&'a AttribEntity),
+}
+
+/// Finds the entities along a text's path. The first ID is a top-level
+/// entity (`top`); each later one is an entity of the block the previous
+/// one references, or an attribute of a block reference among them -- the
+/// renderer draws a nested reference's attributes beside it, in the
+/// enclosing block's frame. Blocks are indexed once, on first use.
+struct PathIndex<'a> {
+ tables: &'a Tables,
+ top: &'a BTreeMap,
+ blocks: BTreeMap<&'a str, BTreeMap>>,
+}
+
+impl<'a> PathIndex<'a> {
+ fn block(&mut self, name: &'a str) -> Option<&BTreeMap>> {
+ if !self.blocks.contains_key(name) {
+ let block: &'a BlockRecord = self.tables.block_records.get(name)?;
+ let mut index = BTreeMap::new();
+ for e in &block.entities {
+ if let Entity::Insert(i) = e {
+ for a in &i.attribs {
+ index
+ .entry(a.common.id)
+ .or_insert(Located::NestedAttribute(a));
+ }
+ }
+ }
+ for e in &block.entities {
+ index.insert(e.common().id, Located::Entity(e));
+ }
+ self.blocks.insert(name, index);
+ }
+ self.blocks.get(name)
+ }
+}
+
+/// The world rotation of a text drawn with `rotation` in the frame `m`: the
+/// angle of its transformed *up* axis less 90 degrees, i.e. how the glyphs
+/// are oriented on the page. Through the up axis rather than the baseline
+/// so that a frame mirrored about the vertical axis (the usual MIRROR of a
+/// block) reports an upright, mirror-written text as 0 rather than 180.
+fn text_rotation(m: &Affine2, rotation: f64) -> f64 {
+ let (ux, uy) = (-rotation.sin(), rotation.cos());
+ let (wx, wy) = (m.a * ux + m.c * uy, m.b * ux + m.d * uy);
+ wy.atan2(wx) - std::f64::consts::FRAC_PI_2
+}
+
+/// The length scale of a frame, `sqrt(|det|)`: exact for a uniform scale,
+/// the geometric mean of the axis scales otherwise.
+fn length_scale(m: &Affine2) -> f64 {
+ m.determinant().abs().sqrt()
+}
+
+/// The point a TEXT or ATTRIB is placed by: its alignment point when it is
+/// justified at all (the file states one then), its start point otherwise.
+fn text_base(
+ start: Point2D,
+ alignment: Option,
+ h: HorizontalJustification,
+ v: VerticalJustification,
+) -> Point2D {
+ if h != HorizontalJustification::Left || v != VerticalJustification::Baseline {
+ alignment.unwrap_or(start)
+ } else {
+ start
+ }
+}
+
+/// Every text the renderer drew that a reader should find, as the package
+/// places it: `boxes` are the scene's texts in drawing order, `top` its
+/// top-level entities by ID, and `keep` says which top-level entities'
+/// texts count (the shown ones -- not hidden, not left out of the picture,
+/// not a dimension, whose label is its own record). A text the drawing
+/// hides (drawn only faded, when hidden entities are included) or that
+/// shows nothing but spaces is not a record.
+pub(crate) fn placed_texts(
+ tables: &Tables,
+ top: &BTreeMap,
+ boxes: &[TextBox],
+ keep: impl Fn(EntityId) -> bool,
+) -> Vec {
+ let mut index = PathIndex {
+ tables,
+ top,
+ blocks: BTreeMap::new(),
+ };
+ let mut seen: BTreeSet = BTreeSet::new();
+ let mut out = Vec::new();
+ for b in boxes {
+ let Some(&first) = b.path.first() else {
+ continue;
+ };
+ if !keep(first) {
+ continue;
+ }
+ let Some(placed) = place(&mut index, b) else {
+ continue;
+ };
+ if seen.insert(placed.id.clone()) {
+ out.push(placed);
+ }
+ }
+ out
+}
+
+fn place(index: &mut PathIndex<'_>, b: &TextBox) -> Option {
+ let tables = index.tables;
+ let mut current = Located::Entity(*index.top.get(b.path.first()?)?);
+ let mut affine = Affine2::IDENTITY;
+ let mut reference_layer: Option = None;
+ for id in &b.path[1..] {
+ let Located::Entity(owner) = current else {
+ return None;
+ };
+ if entity_hidden(owner, tables, reference_layer.as_deref()) {
+ return None;
+ }
+ let (block, placement) = match owner {
+ Entity::Insert(i) => (&i.block_name, Affine2::from_insert(i)),
+ Entity::AcadTable(t) => (
+ &t.block_name,
+ Affine2::placement(
+ Point2D {
+ x: t.insertion_point.x,
+ y: t.insertion_point.y,
+ },
+ t.scale.x,
+ t.scale.y,
+ t.rotation,
+ ),
+ ),
+ Entity::Dimension(d) => (&d.block_name, Affine2::IDENTITY),
+ _ => return None,
+ };
+ affine = placement.then(&affine);
+ reference_layer = Some(
+ effective_layer(owner.common().layer.name(), reference_layer.as_deref()).to_string(),
+ );
+ let children = index.block(block.resolved()?)?;
+ current = *children.get(id)?;
+ }
+ let reference = reference_layer.as_deref();
+ let (common, hidden) = match current {
+ Located::Entity(e) => (e.common(), entity_hidden(e, tables, reference)),
+ Located::NestedAttribute(a) => (
+ &a.common,
+ is_hidden(&a.common, a.flags.invisible, tables, reference),
+ ),
+ };
+ if hidden {
+ return None;
+ }
+ struct Leaf<'b> {
+ kind: &'static str,
+ raw: &'b str,
+ text: String,
+ base: Point2D,
+ plane: Affine2,
+ height: f64,
+ rotation: f64,
+ style: &'b Ref,
+ tag: Option<&'b str>,
+ }
+ let attribute = |a: &'_ AttribEntity| -> (Point2D, Affine2) {
+ (
+ text_base(
+ a.start_point,
+ a.alignment_point,
+ a.horizontal_justification,
+ a.vertical_justification,
+ ),
+ plane_to_world(a.extrusion, a.elevation),
+ )
+ };
+ let no_style = Ref::Absent;
+ let leaf = match current {
+ Located::Entity(Entity::Text(t)) => Leaf {
+ kind: "TEXT",
+ raw: &t.text,
+ text: decode_text(&t.text).plain,
+ base: text_base(
+ t.start_point,
+ t.alignment_point,
+ t.horizontal_justification,
+ t.vertical_justification,
+ ),
+ plane: plane_to_world(t.extrusion, t.elevation),
+ height: t.text_height,
+ rotation: t.rotation,
+ style: &t.style_name,
+ tag: None,
+ },
+ Located::Entity(Entity::Attrib(a)) | Located::NestedAttribute(a) => {
+ let (base, plane) = attribute(a);
+ Leaf {
+ kind: "ATTRIB",
+ raw: &a.text,
+ text: decode_text(&a.text).plain,
+ base,
+ plane,
+ height: a.text_height,
+ rotation: a.rotation,
+ style: &a.style_name,
+ tag: Some(&a.tag),
+ }
+ }
+ Located::Entity(Entity::MText(m)) => Leaf {
+ kind: "MTEXT",
+ raw: &m.text,
+ text: decode_mtext(&m.text).plain,
+ base: Point2D {
+ x: m.insertion_point.x,
+ y: m.insertion_point.y,
+ },
+ plane: Affine2::IDENTITY,
+ height: m.text_height,
+ rotation: m.rotation,
+ style: &m.style_name,
+ tag: None,
+ },
+ Located::Entity(Entity::Tolerance(t)) => Leaf {
+ kind: "TOLERANCE",
+ raw: &t.text_value,
+ text: decode_text(&t.text_value).plain,
+ base: Point2D {
+ x: t.insertion_point.x,
+ y: t.insertion_point.y,
+ },
+ plane: Affine2::IDENTITY,
+ height: tolerance_text_height(t, tables),
+ rotation: 0.0,
+ style: &no_style,
+ tag: None,
+ },
+ _ => return None,
+ };
+ if leaf.text.trim().is_empty() {
+ return None;
+ }
+ let frame = leaf.plane.then(&affine);
+ let anchor = frame.apply(leaf.base);
+ let bbox = b
+ .measured
+ .or(b.estimate)
+ .map(Rect::from)
+ .unwrap_or(Rect::at(anchor.x, anchor.y));
+ Some(PlacedText {
+ id: record_id(&b.path),
+ part: b.path[0],
+ handle: handle_of(common),
+ kind: leaf.kind,
+ layer: effective_layer(common.layer.name(), reference).to_string(),
+ raw: leaf.raw.to_string(),
+ text: leaf.text,
+ height: leaf.height * length_scale(&frame),
+ rotation: text_rotation(&frame, leaf.rotation),
+ anchor,
+ bbox,
+ measured: b.measured.is_some(),
+ unshaped: b.missing_glyphs,
+ style: leaf.style.name().to_string(),
+ tag: leaf.tag.map(str::to_string),
+ })
+}
+
+/// One text's record, for `texts.json`.
+///
+/// `space` is `model` or `paper`; a paper-space text also names the
+/// `sheet` it is on and carries that sheet image's pixel box instead of
+/// tiles, since the model tile pyramid does not cover the paper. Its
+/// `bbox` and `anchor` are in the layout's paper units, not world units --
+/// the only records in the package that are.
+pub(crate) fn text_record(
+ t: &PlacedText,
+ rounder: &Rounder,
+ space: &str,
+ sheet: Option<&str>,
+ measured_why: &str,
+) -> Record {
+ let mut v = Map::new();
+ v.insert("id".into(), json!(t.id));
+ v.insert("handle".into(), t.handle.clone());
+ v.insert("kind".into(), json!(t.kind));
+ v.insert("text".into(), json!(t.text));
+ if t.raw != t.text {
+ v.insert("raw".into(), json!(t.raw));
+ }
+ if let Some(tag) = &t.tag {
+ v.insert("tag".into(), json!(tag));
+ }
+ v.insert("space".into(), json!(space));
+ if let Some(name) = sheet {
+ v.insert("sheet".into(), json!(name));
+ }
+ v.insert("layer".into(), json!(t.layer));
+ v.insert("height".into(), json!(rounder.derived(t.height)));
+ v.insert(
+ "rotation_deg".into(),
+ json!(rounder.derived(t.rotation.to_degrees())),
+ );
+ v.insert("anchor".into(), rounder.pt2(t.anchor));
+ if !t.style.is_empty() {
+ v.insert("style".into(), json!(t.style));
+ }
+ v.insert("bbox".into(), rounder.rect(&t.bbox));
+ v.insert(
+ "bbox_confidence".into(),
+ json!(if t.measured { "measured" } else { "estimated" }),
+ );
+ v.insert(
+ "why".into(),
+ json!(if t.measured {
+ measured_why
+ } else {
+ "the renderer's estimate: 0.6 em per character from the anchor"
+ }),
+ );
+ v.insert("font_ok".into(), json!(t.unshaped == 0));
+ if t.unshaped > 0 {
+ v.insert("unshaped_glyphs".into(), json!(t.unshaped));
+ }
+ Record {
+ id: t.id.clone(),
+ bbox: t.bbox,
+ value: v,
+ }
+}
+
+// ------------------------------------------------------------ confidence
+
+/// A record's `confidence`, never above the model's for the entity it is
+/// made from (the model's invariant: a value that entered low never leaves
+/// high). The model's `HIGH` changes nothing; below it, a value the package
+/// would call `exact` or `stored` is `estimated`, with the reason.
+pub(crate) fn capped_confidence(
+ confidence: &'static str,
+ model: Confidence,
+) -> (&'static str, Option<&'static str>) {
+ match (model, confidence) {
+ (Confidence::High, _) => (confidence, None),
+ (_, "exact" | "stored") => (
+ "estimated",
+ Some("the reader that produced the model trusts this entity's values less than fully"),
+ ),
+ _ => (confidence, None),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn ids_sort_by_their_numbers() {
+ let path = [EntityId::new(0x125), EntityId::new(270)];
+ assert_eq!(record_id(&path), "293/270");
+ assert_eq!(record_id(&path[..1]), "293");
+ assert_eq!(id_key("293/270"), vec![293, 270]);
+ assert!(id_key("31") < id_key("293"), "numbers, not strings");
+ assert!(id_key("293") < id_key("293/1"));
+ assert_eq!(id_key("x"), vec![u64::MAX]);
+ }
+
+ #[test]
+ fn rounding() {
+ assert_eq!(round_to(1.23456789, 3), 1.235);
+ assert_eq!(round_to(-0.0001, 3), 0.0);
+ }
+
+ #[test]
+ fn coordinate_decimals_never_fall_below_the_scale() {
+ // An ordinary drawing: a 10 000-unit plan on a 1568 px overview is
+ // about 0.15 px/unit, 4.8 at the deepest of five levels, so four
+ // decimals put one ulp at 5e-4 px.
+ assert_eq!(coord_decimals(0, 4.8), 4);
+ assert_eq!(coord_decimals(4, 4.8), 4);
+ assert_eq!(coord_decimals(8, 4.8), 8, "a precise drawing keeps its 8");
+ // A drawing 0.004 units across: 8.7e6 px/unit at z5. Ten decimals
+ // keep one ulp at a thousandth of a pixel.
+ assert_eq!(coord_decimals(4, 8.7e6), 10);
+ assert!(10f64.powi(-10) * 8.7e6 < 1e-3);
+ // Clamped at both ends; a scale that is not a number falls back to
+ // the LUPREC floor.
+ assert_eq!(coord_decimals(99, 4.8), 12);
+ assert_eq!(coord_decimals(4, 1e30), 12);
+ assert_eq!(coord_decimals(4, f64::NAN), 4);
+ assert_eq!(coord_decimals(4, 0.0), 4);
+ }
+
+ #[test]
+ fn a_frame_reports_the_text_orientation_a_reader_sees() {
+ let quarter = std::f64::consts::FRAC_PI_2;
+ // A 90-degree child under a parent mirrored about the vertical
+ // axis: a text along the child's x axis runs up the page with its
+ // glyph tops to +x, which reads as -90 degrees.
+ let child = Affine2::placement(Point2D { x: 0.0, y: 0.0 }, 1.0, 1.0, quarter);
+ let mirror = Affine2::placement(Point2D { x: 100.0, y: 100.0 }, -1.0, 1.0, 0.0);
+ let both = child.then(&mirror);
+ assert!((text_rotation(&both, 0.0) + quarter).abs() < 1e-12);
+ // The top-level mirror alone keeps a horizontal text at 0 (the
+ // glyphs are mirrored, not turned).
+ assert!(text_rotation(&mirror, 0.0).abs() < 1e-12);
+ assert!((length_scale(&both) - 1.0).abs() < 1e-12);
+ let stretch = Affine2::placement(Point2D { x: 0.0, y: 0.0 }, 2.0, 1.0, 0.0);
+ assert!((length_scale(&stretch) - 2f64.sqrt()).abs() < 1e-12);
+ }
+
+ #[test]
+ fn a_record_never_claims_more_than_the_model() {
+ assert_eq!(
+ capped_confidence("exact", Confidence::High),
+ ("exact", None)
+ );
+ assert_eq!(capped_confidence("exact", Confidence::Low).0, "estimated");
+ assert_eq!(
+ capped_confidence("stored", Confidence::Unknown).0,
+ "estimated"
+ );
+ assert_eq!(
+ capped_confidence("unavailable", Confidence::Low),
+ ("unavailable", None)
+ );
+ }
+}
diff --git a/crates/uncad-export/src/package/sheets.rs b/crates/uncad-export/src/package/sheets.rs
new file mode 100644
index 0000000..b033ff3
--- /dev/null
+++ b/crates/uncad-export/src/package/sheets.rs
@@ -0,0 +1,427 @@
+//! Sheets: one image per paper layout, the model composited through its
+//! viewports by the renderer's layout rendering, with what each viewport
+//! shows, how a model point maps onto the sheet, and the sheet's own texts
+//! -- the title and the title block -- as records on it.
+
+use std::collections::{BTreeMap, BTreeSet};
+
+use iron_render_cad::{limits::LimitReport, Background, Scene, SheetSource, ToSvgOptions};
+use serde::Serialize;
+use uncad_model::model::{Entity, EntityId, Point2D, Ref, ViewportEntity};
+use uncad_model::tables::{LayoutRecord, PlotPaperUnits, PlotSettings};
+use uncad_model::CadDatabase;
+
+use super::output::to_rgb;
+use super::records::{placed_texts, PlacedText, Rounder};
+use super::{fit_overview, ExportError, ExportOptions, ImageInfo, STROKE_PX};
+use crate::frame::{Rect, EMPTY_RECT};
+
+/// A viewport on a sheet, as `sheets.json` lists it.
+#[derive(Debug, Clone, PartialEq, Serialize)]
+pub struct SheetViewport {
+ /// The VIEWPORT's reference ID in the model.
+ pub id: u64,
+ /// The file's handle for it, when it came from a file.
+ pub handle: Option,
+ /// The number the file gives it (DXF 69); a DWG states none.
+ pub viewport_id: Option,
+ /// Whether it is on: a viewport the file does not say is off is on.
+ pub on: bool,
+ /// The sheet's own frame (the paper), never composited.
+ pub overall: bool,
+ /// Whether the model was drawn through it.
+ pub composited: bool,
+ /// The frame on the sheet, paper units.
+ pub frame: Rect,
+ /// Paper units per model unit.
+ pub scale: Option,
+ pub twist_deg: f64,
+ /// World corners of the model window (lower-left, lower-right,
+ /// upper-right, upper-left on the sheet), when composited.
+ pub model_window: Option<[[f64; 2]; 4]>,
+ /// Row-major `[a, b, c, d, e, f]` with `paper_x = a x + b y + c` and
+ /// `paper_y = d x + e y + f` for a model point `(x, y)`, when
+ /// composited: the map the renderer drew the model through.
+ pub model_to_paper: Option<[f64; 6]>,
+ pub frozen_layers: Vec,
+}
+
+/// One paper layout: its sheet, its viewports and its image.
+#[derive(Debug, Clone, PartialEq, Serialize)]
+pub struct SheetReport {
+ pub name: String,
+ pub tab_order: i32,
+ pub block: String,
+ /// `mm`, `in` or `px` -- the layout's paper unit.
+ pub units: String,
+ /// The plot settings, when the file has a LAYOUT for this sheet.
+ pub plot: Option,
+ /// The sheet rectangle in paper units and where it came from, in order
+ /// of preference: `layout_limits` (the LAYOUT's LIMMIN/LIMMAX, AutoCAD's
+ /// own placement of the paper), `paper_size` (the paper its plot
+ /// settings describe), `entities` (the paper entities' extents) or
+ /// `empty`.
+ pub rect: Rect,
+ pub rect_source: String,
+ pub overview: ImageInfo,
+ pub viewports: Vec,
+ /// The sheet's own entities the drawing shows (viewport frames
+ /// included).
+ pub entities: usize,
+}
+
+/// A paper layout to export.
+struct SheetSpec {
+ /// The key of `tables.layouts` the renderer knows it by.
+ name: String,
+ tab_order: i32,
+ block: String,
+ units: String,
+ plot: Option,
+}
+
+/// The sheets written, their paper texts (each with the index of its
+/// sheet), and every bound their renders engaged.
+#[derive(Default)]
+pub(crate) struct Sheets {
+ pub(crate) reports: Vec,
+ pub(crate) texts: Vec<(PlacedText, usize)>,
+ pub(crate) images: Vec<(String, Vec)>,
+ pub(crate) limits: Vec,
+}
+
+/// The most characters of a layout name a sheet directory keeps: well under
+/// every per-component limit even after the `_99` dedup suffix, and under
+/// what is left of Windows' 260-character path budget once the package
+/// directory and `sheets//overview.png` are counted.
+const MAX_SHEET_DIR: usize = 100;
+
+/// The directory one sheet's image goes in, under `sheets/`: the layout's
+/// name with every character outside `[A-Za-z0-9_-]` replaced by `_` (so
+/// the path is portable), cut to [`MAX_SHEET_DIR`] characters, `sheet` when
+/// nothing is left of it, and `_2`, `_3`, ... in tab order when an earlier
+/// layout already took the name -- every Hangul syllable sanitises to `_`,
+/// so two three-syllable Korean names both become `___`.
+fn sheet_dir(name: &str, used: &mut BTreeSet) -> String {
+ let safe: String = name
+ .chars()
+ .map(|c| {
+ if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
+ c
+ } else {
+ '_'
+ }
+ })
+ // Sanitising leaves pure ASCII, so this cuts characters and bytes
+ // alike and can never split one.
+ .take(MAX_SHEET_DIR)
+ .collect();
+ let base = if safe.is_empty() {
+ "sheet".to_string()
+ } else {
+ safe
+ };
+ let mut candidate = base.clone();
+ let mut n = 2;
+ while !used.insert(candidate.clone()) {
+ candidate = format!("{base}_{n}");
+ n += 1;
+ }
+ candidate
+}
+
+/// The unit a layout's paper is set up in.
+fn paper_units(plot: &PlotSettings) -> &'static str {
+ match plot.paper_units {
+ Some(PlotPaperUnits::Inches) => "in",
+ Some(PlotPaperUnits::Pixels) => "px",
+ _ => "mm",
+ }
+}
+
+/// The paper layouts of `db`, in tab order: the LAYOUT objects whose block
+/// the model holds -- or, for a file without them (R13/R14, a DXF without
+/// an OBJECTS section), every paper-space block with entities, which the
+/// renderer can only draw as a layout: `db` is then returned with a
+/// layout stated for each, one that states no sheet, so it is framed on its
+/// paper entities.
+fn sheet_specs(db: &CadDatabase, unit: &str) -> (Vec, Option) {
+ let mut specs: Vec = db
+ .tables
+ .layouts
+ .iter()
+ .filter(|(_, l)| {
+ l.tab_order > 0
+ && l.block_name
+ .resolved()
+ .is_some_and(|b| db.tables.block_records.contains_key(b))
+ })
+ .map(|(key, l)| SheetSpec {
+ name: key.clone(),
+ tab_order: l.tab_order,
+ block: l.block_name.name().to_string(),
+ units: paper_units(&l.plot_settings).to_string(),
+ plot: Some(l.plot_settings.clone()),
+ })
+ .collect();
+ let mut synthetic = None;
+ if specs.is_empty() {
+ let mut tab = 1;
+ for (name, block) in &db.tables.block_records {
+ if name.to_uppercase().starts_with("*PAPER_SPACE") && !block.entities.is_empty() {
+ specs.push(SheetSpec {
+ name: name.trim_start_matches('*').to_string(),
+ tab_order: tab,
+ block: name.clone(),
+ units: unit.to_string(),
+ plot: None,
+ });
+ tab += 1;
+ }
+ }
+ if !specs.is_empty() {
+ let mut stated = db.clone();
+ for spec in &specs {
+ stated.tables.layouts.insert(
+ spec.name.clone(),
+ LayoutRecord {
+ name: spec.name.clone(),
+ tab_order: spec.tab_order,
+ block_name: Ref::Resolved(spec.block.clone()),
+ limits_min: Point2D::default(),
+ limits_max: Point2D::default(),
+ plot_settings: PlotSettings {
+ paper_name: String::new(),
+ paper_width: 0.0,
+ paper_height: 0.0,
+ margin_left: 0.0,
+ margin_bottom: 0.0,
+ margin_right: 0.0,
+ margin_top: 0.0,
+ plot_origin: Point2D::default(),
+ paper_units: None,
+ rotation: None,
+ scale_numerator: 0.0,
+ scale_denominator: 0.0,
+ },
+ },
+ );
+ }
+ synthetic = Some(stated);
+ }
+ }
+ specs.sort_by(|a, b| a.tab_order.cmp(&b.tab_order).then(a.name.cmp(&b.name)));
+ (specs, synthetic)
+}
+
+/// Draws every paper layout of `db` as its sheet and collects its texts.
+pub(crate) fn export_sheets(
+ db: &CadDatabase,
+ unit: &str,
+ options: &ExportOptions,
+ cap_height: f64,
+ rounder: &Rounder,
+ warnings: &mut Vec,
+) -> Result {
+ let (specs, synthetic) = sheet_specs(db, unit);
+ let db = synthetic.as_ref().unwrap_or(db);
+ let fonts = &options.fonts;
+ let mut out = Sheets {
+ reports: Vec::new(),
+ texts: Vec::new(),
+ images: Vec::new(),
+ limits: Vec::new(),
+ };
+ let mut dirs: BTreeSet = BTreeSet::new();
+ // Two LAYOUTs of a malformed file can name one paper block; a record
+ // file must not hold an id twice.
+ let mut text_ids: BTreeSet = BTreeSet::new();
+ for spec in specs {
+ let sheet = match Scene::layout(
+ db,
+ &spec.name,
+ ToSvgOptions {
+ crop: iron_render_cad::Crop::Everything,
+ padding: 0.0,
+ include_hidden: options.include_hidden,
+ cap_height,
+ ..ToSvgOptions::default()
+ },
+ ) {
+ Ok(sheet) => sheet,
+ Err(e) => {
+ warnings.push(format!(
+ "UnusableSheet: layout {} ({}) cannot be drawn: {e}",
+ spec.name, spec.block
+ ));
+ continue;
+ }
+ };
+ let (rect, rect_source) = match (sheet.sheet, sheet.crop.content) {
+ (Some(SheetSource::Limits), _) => (Rect::from(sheet.view_box), "layout_limits"),
+ (Some(SheetSource::PlotSettings), _) => (Rect::from(sheet.view_box), "paper_size"),
+ (Some(_), _) => (Rect::from(sheet.view_box), "stated"),
+ (None, Some(content)) => (Rect::from(content), "entities"),
+ (None, None) => (EMPTY_RECT, "empty"),
+ };
+ // No paper size, no limits and nothing but point-like content (a
+ // lone POINT, a zero-length LINE): there is no rectangle to fit,
+ // and one unusable sheet is worth a warning, not the whole export.
+ let usable = [rect.min_x, rect.min_y, rect.max_x, rect.max_y]
+ .iter()
+ .all(|v| v.is_finite() && v.abs() < 1e15)
+ && (rect.width() > 0.0 || rect.height() > 0.0);
+ if !usable {
+ warnings.push(format!(
+ "UnusableSheet: layout {} ({}) has no paper size, no limits and no usable content ({rect_source}); its sheet is skipped",
+ spec.name, spec.block
+ ));
+ continue;
+ }
+ let fit = fit_overview(&rect, &options.profile, Some(0.0));
+ let png_path = format!("sheets/{}/overview.png", sheet_dir(&spec.name, &mut dirs));
+ let overview = ImageInfo::new(
+ &format!("sheet:{}", spec.name),
+ &png_path,
+ fit.rect,
+ fit.ppu,
+ fit.width,
+ fit.height,
+ );
+ let bytes = to_rgb(&sheet.png(
+ &overview.view(),
+ STROKE_PX,
+ fonts,
+ Background::White,
+ |_| true,
+ )?)?;
+ out.images.push((png_path, bytes));
+
+ let block_entities: &[Entity] = db
+ .tables
+ .block_records
+ .get(&spec.block)
+ .map_or(&[], |b| b.entities.as_slice());
+ let viewport_entities: BTreeMap = block_entities
+ .iter()
+ .filter_map(|e| match e {
+ Entity::Viewport(v) => Some((v.common.id, v)),
+ _ => None,
+ })
+ .collect();
+ let viewports: Vec = sheet
+ .viewports
+ .iter()
+ .map(|r| {
+ let vp = viewport_entities.get(&r.id);
+ let view = vp.and_then(|v| v.view);
+ let m = r.model_to_paper;
+ SheetViewport {
+ id: r.id.value(),
+ handle: vp.and_then(|v| v.common.source_handle.resolved().cloned()),
+ viewport_id: vp.and_then(|v| v.viewport_id),
+ on: vp.is_none_or(|v| v.on != Some(false)),
+ overall: r.overall,
+ composited: m.is_some(),
+ frame: Rect::from(r.frame),
+ scale: match (m, vp, view) {
+ (Some(m), _, _) => Some(m.determinant().abs().sqrt()),
+ (None, Some(v), Some(view)) if view.height > 0.0 && v.height > 0.0 => {
+ Some(v.height / view.height)
+ }
+ _ => None,
+ },
+ twist_deg: match (m, view) {
+ (Some(m), _) => m.b.atan2(m.a).to_degrees(),
+ (None, Some(view)) => view.twist.to_degrees(),
+ (None, None) => 0.0,
+ },
+ model_window: r
+ .model_window
+ .map(|w| w.map(|p| [rounder.coord(p.x), rounder.coord(p.y)])),
+ model_to_paper: m.map(|m| [m.a, m.c, m.e, m.b, m.d, m.f]),
+ frozen_layers: vp.map_or(Vec::new(), |v| {
+ v.frozen_layers
+ .iter()
+ .map(|l| l.name().to_string())
+ .collect()
+ }),
+ }
+ })
+ .collect();
+ let entities = sheet
+ .parts()
+ .iter()
+ .filter(|p| !p.through_viewport && p.hidden.is_none())
+ .count();
+
+ // The sheet's own texts: the ones whose path does not start at a
+ // viewport (the model drawn through one starts there), placed on
+ // the paper, measured through the same fonts the sheet image got.
+ let viewport_ids: BTreeSet = sheet.viewports.iter().map(|v| v.id).collect();
+ let boxes = sheet.text_boxes(fonts)?;
+ let mut top: BTreeMap =
+ db.entities.iter().map(|e| (e.common().id, e)).collect();
+ for e in block_entities {
+ top.insert(e.common().id, e);
+ }
+ let sheet_index = out.reports.len();
+ let texts = placed_texts(&db.tables, &top, &boxes, |id| !viewport_ids.contains(&id));
+ out.texts.extend(
+ texts
+ .into_iter()
+ .filter(|t| text_ids.insert(t.id.clone()))
+ .map(|t| (t, sheet_index)),
+ );
+ out.limits.push(sheet.limits.clone());
+ out.reports.push(SheetReport {
+ name: spec.name,
+ tab_order: spec.tab_order,
+ block: spec.block,
+ units: spec.units,
+ plot: spec.plot,
+ rect,
+ rect_source: rect_source.to_string(),
+ overview,
+ viewports,
+ entities,
+ });
+ }
+ Ok(out)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn sheet_directories_are_unique() {
+ let mut used = BTreeSet::new();
+ assert_eq!(sheet_dir("\u{d3c9}\u{ba74}\u{b3c4}", &mut used), "___");
+ assert_eq!(sheet_dir("\u{c785}\u{ba74}\u{b3c4}", &mut used), "____2");
+ assert_eq!(sheet_dir("Layout 1", &mut used), "Layout_1");
+ assert_eq!(sheet_dir("Layout_1", &mut used), "Layout_1_2");
+ assert_eq!(sheet_dir("Layout-1", &mut used), "Layout-1");
+ assert_eq!(sheet_dir("", &mut used), "sheet");
+ assert_eq!(sheet_dir("", &mut used), "sheet_2");
+ assert_eq!(used.len(), 7, "every name got its own directory");
+ }
+
+ #[test]
+ fn a_sheet_directory_is_short_enough_for_the_filesystem() {
+ // A layout name longer than the filesystem's 255-byte component
+ // limit made the first sheet write fail, and the export with it.
+ let mut used = BTreeSet::new();
+ let long = "L".repeat(300);
+ let first = sheet_dir(&long, &mut used);
+ assert_eq!(first, "L".repeat(MAX_SHEET_DIR));
+ let second = sheet_dir(&format!("{long}-other"), &mut used);
+ assert_eq!(second, format!("{}_2", "L".repeat(MAX_SHEET_DIR)));
+ for dir in [&first, &second] {
+ assert!(dir.len() < 255, "{} bytes", dir.len());
+ assert!(dir.is_ascii());
+ }
+ let exact = "N".repeat(MAX_SHEET_DIR);
+ assert_eq!(sheet_dir(&exact, &mut used), exact);
+ }
+}
diff --git a/crates/uncad-export/src/package/tiles.rs b/crates/uncad-export/src/package/tiles.rs
new file mode 100644
index 0000000..d6fa125
--- /dev/null
+++ b/crates/uncad-export/src/package/tiles.rs
@@ -0,0 +1,745 @@
+//! Frames and their tile pyramids: the drawing's detached groups, each with
+//! its own overview when there is more than one; zoom levels deep enough for
+//! the frame's text; overlapping tiles of every level, culled to the ones
+//! something visible reaches; and the sidecar beside each tile saying what
+//! is on it.
+
+use std::collections::{BTreeMap, BTreeSet};
+
+use iron_render_cad::{Background, Fonts, Part, PngError, Scene};
+use serde_json::{json, Value};
+use uncad_model::model::{EntityId, Point2D};
+
+use super::output::to_rgb;
+use super::records::{PlacedText, Record, Rounder};
+use super::{
+ fit_overview, ExportError, ExportOptions, FrameReport, HeightClass, ImageInfo, LevelInfo,
+ Profile, SCHEMA, STROKE_PX,
+};
+use crate::frame::{detached_groups, Rect};
+
+/// The most entries `manifest.frames_dropped` lists (the count is always
+/// exact in `frames_dropped_total`).
+const MAX_DROPPED_FRAMES: usize = 100;
+
+pub(crate) struct Tile {
+ pub(crate) id: String,
+ pub(crate) frame: String,
+ pub(crate) z: u32,
+ pub(crate) row: u32,
+ pub(crate) col: u32,
+ /// Pixel origin on the level canvas.
+ pub(crate) origin_px: (u32, u32),
+ pub(crate) width: u32,
+ pub(crate) height: u32,
+ pub(crate) world: Rect,
+ pub(crate) empty: bool,
+}
+
+pub(crate) struct FrameBuild {
+ pub(crate) report: FrameReport,
+ pub(crate) tiles: Vec,
+}
+
+/// The frames a drawing is split into, and the groups that were not made
+/// frames.
+pub(crate) struct FramePlan {
+ pub(crate) frames: Vec,
+ /// `manifest.frames_dropped`: at most [`MAX_DROPPED_FRAMES`] entries.
+ pub(crate) dropped: Vec,
+ pub(crate) dropped_total: usize,
+}
+
+/// Splits the drawing into frames -- `f0` the largest connected group of
+/// entities, a detached group (a detail drawn beside the plan) its own when
+/// it holds `min_frame_entities` entities or a text -- and plans each
+/// frame's tile pyramid within the tile budget.
+///
+/// `extents` are the drawn entities' boxes, in drawing order; `crop` the
+/// whole crop (the one frame of a drawing that does not split); `overview`
+/// the whole crop's image, which a one-frame drawing reuses.
+#[allow(clippy::too_many_arguments)]
+pub(crate) fn plan_frames(
+ extents: &[Rect],
+ crop: Rect,
+ texts: &[PlacedText],
+ overview: &ImageInfo,
+ options: &ExportOptions,
+ rounder: &Rounder,
+ warnings: &mut Vec,
+) -> FramePlan {
+ let groups = detached_groups(extents, options.frame_gap * crop.diagonal());
+ let group_rect =
+ |g: &[usize]| Rect::bounding(g.iter().map(|i| &extents[*i])).expect("non-empty");
+ let group_texts = |r: &Rect| texts.iter().filter(|t| r.intersects(&t.bbox)).count();
+ let mut specs: Vec<(String, &str, Rect, usize)> = Vec::new();
+ // Every group that did not become a frame, with the reason: a reader
+ // seeing a record with `tiles: []` needs to know whether that part of
+ // the drawing was left out on purpose. The list is capped (a drawing
+ // of scattered symbols can have thousands of groups) and
+ // `frames_dropped_total` says how many there were.
+ let mut dropped: Vec = Vec::new();
+ let mut dropped_total = 0usize;
+ let (mut dropped_small, mut dropped_over_max) = (0usize, 0usize);
+ if groups.len() <= 1 {
+ specs.push(("f0".to_string(), "primary", crop, extents.len()));
+ } else {
+ for (n, g) in groups.iter().enumerate() {
+ let r = group_rect(g);
+ let qualifies = n == 0 || g.len() >= options.min_frame_entities || group_texts(&r) > 0;
+ let reason = if !qualifies {
+ dropped_small += 1;
+ "below_min_entities"
+ } else if specs.len() >= options.max_frames.max(1) {
+ dropped_over_max += 1;
+ "max_frames"
+ } else {
+ let id = format!("f{}", specs.len());
+ specs.push((id, if n == 0 { "primary" } else { "detached" }, r, g.len()));
+ continue;
+ };
+ dropped_total += 1;
+ if dropped.len() < MAX_DROPPED_FRAMES {
+ dropped.push(json!({
+ "content": rounder.rect(&r),
+ "entities": g.len(),
+ "texts": group_texts(&r),
+ "reason": reason,
+ }));
+ }
+ }
+ if dropped_over_max > 0 {
+ warnings.push(format!(
+ "MaxFrames: {dropped_over_max} detached groups beyond the {} frames written stay in the overview only (frames_dropped)",
+ options.max_frames
+ ));
+ }
+ if dropped_small > 0 {
+ warnings.push(format!(
+ "SmallGroups: {dropped_small} detached groups hold fewer than {} entities and no text; they stay in the overview only, so their records carry `tiles: []` (frames_dropped)",
+ options.min_frame_entities
+ ));
+ }
+ }
+ let mut frames = Vec::new();
+ let mut tile_budget = options.max_tiles;
+ let single = specs.len() == 1;
+ for (id, kind, content, entities) in &specs {
+ let reuse = single.then(|| overview.clone());
+ frames.push(build_frame(
+ id,
+ kind,
+ *content,
+ *entities,
+ texts,
+ extents,
+ options,
+ reuse,
+ &mut tile_budget,
+ warnings,
+ ));
+ }
+ FramePlan {
+ frames,
+ dropped,
+ dropped_total,
+ }
+}
+
+/// Plans one frame: its overview (or the whole-crop one when `reuse` is
+/// given), its depth from the texts inside it, and its tiles within the
+/// remaining `tile_budget`.
+#[allow(clippy::too_many_arguments)]
+fn build_frame(
+ id: &str,
+ kind: &str,
+ content: Rect,
+ entities: usize,
+ texts: &[PlacedText],
+ extents: &[Rect],
+ options: &ExportOptions,
+ reuse: Option,
+ tile_budget: &mut usize,
+ warnings: &mut Vec,
+) -> FrameBuild {
+ let profile = &options.profile;
+ let overview = reuse.unwrap_or_else(|| {
+ let fit = fit_overview(&content, profile, options.padding);
+ ImageInfo::new(
+ &format!("{id}/ov"),
+ &format!("frames/{id}/overview.png"),
+ fit.rect,
+ fit.ppu,
+ fit.width,
+ fit.height,
+ )
+ });
+ let (rect0, w0, h0, ppu_0) = (overview.world, overview.px[0], overview.px[1], overview.ppu);
+ let inside: Vec<&PlacedText> = texts
+ .iter()
+ .filter(|t| content.intersects(&t.bbox))
+ .collect();
+ let heights = height_classes(&inside);
+ let z_max = depth_for(&heights, ppu_0, options);
+ let mut levels: Vec = Vec::new();
+ let mut tiles: Vec = Vec::new();
+ let mut reached = true;
+ for z in 1..=z_max {
+ let ppu = ppu_0 * 2f64.powi(z as i32);
+ let (cw, ch) = (w0 * 2u32.pow(z), h0 * 2u32.pow(z));
+ let plan = plan_tiles(id, z, cw, ch, &rect0, ppu, profile, extents);
+ let written = plan.iter().filter(|t| !t.empty).count();
+ if written > *tile_budget {
+ reached = false;
+ warnings.push(format!(
+ "MaxTiles: frame {id} level z{z} would need {written} tiles with {} left of {}; stopping at z{}",
+ *tile_budget,
+ options.max_tiles,
+ z - 1
+ ));
+ break;
+ }
+ *tile_budget -= written;
+ let (cols, rows) = grid(cw, ch, profile);
+ levels.push(LevelInfo {
+ z,
+ ppu,
+ canvas_px: [cw, ch],
+ cols,
+ rows,
+ tile_px: profile.tile,
+ overlap_px: profile.overlap,
+ step_px: profile.tile - profile.overlap,
+ tiles_written: written,
+ tiles_empty: plan.len() - written,
+ });
+ tiles.extend(plan);
+ }
+ let z_reached = levels.last().map_or(0, |l| l.z);
+ let height_classes = heights
+ .iter()
+ .map(|(h, n)| {
+ let px = h * ppu_0 * 2f64.powi(z_reached as i32);
+ HeightClass {
+ height: super::records::round_to(*h, 6),
+ count: *n,
+ px_at_zmax: super::records::round_to(px, 3),
+ legible: px >= options.target_text_px,
+ }
+ })
+ .collect();
+ FrameBuild {
+ report: FrameReport {
+ id: id.to_string(),
+ kind: kind.to_string(),
+ content,
+ entities,
+ texts: inside.len(),
+ overview,
+ levels,
+ z_max: z_reached,
+ reached,
+ height_classes,
+ },
+ tiles,
+ }
+}
+
+/// Count-weighted text height classes (heights rounded to 3 decimals),
+/// most common first.
+fn height_classes(texts: &[&PlacedText]) -> Vec<(f64, usize)> {
+ let mut classes: BTreeMap = BTreeMap::new();
+ for t in texts {
+ if t.height > 0.0 && t.height.is_finite() {
+ *classes
+ .entry((t.height * 1000.0).round() as i64)
+ .or_default() += 1;
+ }
+ }
+ let mut out: Vec<(f64, usize)> = classes
+ .into_iter()
+ .map(|(k, n)| (k as f64 / 1000.0, n))
+ .collect();
+ out.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.total_cmp(&b.0)));
+ out
+}
+
+/// The deepest level: where the dominant text class (the count-weighted
+/// median height) reaches the target pixel height; one level without text.
+fn depth_for(heights: &[(f64, usize)], ppu_0: f64, options: &ExportOptions) -> u32 {
+ if options.max_levels == 0 {
+ return 0;
+ }
+ let total: usize = heights.iter().map(|(_, n)| n).sum();
+ if total == 0 {
+ return 1;
+ }
+ let mut sorted: Vec<(f64, usize)> = heights.to_vec();
+ sorted.sort_by(|a, b| a.0.total_cmp(&b.0));
+ let mut seen = 0;
+ let mut median = sorted[0].0;
+ for (h, n) in &sorted {
+ seen += n;
+ if seen * 2 >= total {
+ median = *h;
+ break;
+ }
+ }
+ let px_now = median * ppu_0;
+ if px_now <= 0.0 {
+ return 1;
+ }
+ let z = (options.target_text_px / px_now).log2().ceil();
+ if z.is_finite() {
+ (z.max(1.0) as u32).min(options.max_levels)
+ } else {
+ 1
+ }
+}
+
+fn grid(canvas_w: u32, canvas_h: u32, profile: &Profile) -> (u32, u32) {
+ let step = profile.tile - profile.overlap;
+ let count = |extent: u32| -> u32 {
+ if extent <= profile.tile {
+ 1
+ } else {
+ (extent - profile.tile).div_ceil(step) + 1
+ }
+ };
+ (count(canvas_w), count(canvas_h))
+}
+
+/// The tiles of one level: SAHI-style, the last row and column shifted
+/// inward so every tile is the full size (or the whole canvas when that is
+/// smaller); a tile is empty when no drawn extent touches it.
+#[allow(clippy::too_many_arguments)]
+fn plan_tiles(
+ frame: &str,
+ z: u32,
+ canvas_w: u32,
+ canvas_h: u32,
+ rect0: &Rect,
+ ppu: f64,
+ profile: &Profile,
+ extents: &[Rect],
+) -> Vec {
+ let (cols, rows) = grid(canvas_w, canvas_h, profile);
+ let step = profile.tile - profile.overlap;
+ let origin = |index: u32, extent: u32| -> (u32, u32) {
+ if extent <= profile.tile {
+ (0, extent)
+ } else {
+ let o = (index * step).min(extent - profile.tile);
+ (o, profile.tile)
+ }
+ };
+ let mut tiles = Vec::new();
+ for row in 0..rows {
+ let (oy, th) = origin(row, canvas_h);
+ for col in 0..cols {
+ let (ox, tw) = origin(col, canvas_w);
+ let x0 = rect0.min_x + f64::from(ox) / ppu;
+ let y1 = rect0.max_y - f64::from(oy) / ppu;
+ let world = Rect::new(x0, y1 - f64::from(th) / ppu, x0 + f64::from(tw) / ppu, y1);
+ let empty = !extents.iter().any(|e| e.intersects(&world));
+ tiles.push(Tile {
+ id: format!("{frame}/z{z}/r{row:02}_c{col:02}"),
+ frame: frame.to_string(),
+ z,
+ row,
+ col,
+ origin_px: (ox, oy),
+ width: tw,
+ height: th,
+ world,
+ empty,
+ });
+ }
+ }
+ tiles
+}
+
+/// Which parts an image of `window` draws: every part the crop did not
+/// leave out whose extent reaches the window grown by `margin` (for strokes
+/// and text overhang), every construction line, and every part that
+/// measured no extent -- so rasterizing a tile costs what is on it, not the
+/// whole drawing.
+pub(crate) fn keep_for<'a>(
+ window: Rect,
+ margin: f64,
+ extent_of: &'a BTreeMap,
+) -> impl Fn(&Part) -> bool + 'a {
+ let grown = window.padded(margin);
+ move |p: &Part| {
+ p.left_out.is_none()
+ && (p.unbounded
+ || extent_of
+ .get(&p.id)
+ .is_none_or(|rect| rect.intersects(&grown)))
+ }
+}
+
+/// Renders `images` on as many threads as the machine offers (at most one
+/// per image), returning the 8-bit RGB PNG bytes in their order. A thread
+/// that panics ends the export with [`PngError::RenderPanic`], not with the
+/// process.
+pub(crate) fn render_images(
+ scene: &Scene,
+ fonts: &Fonts,
+ extent_of: &BTreeMap,
+ images: &[&ImageInfo],
+) -> Result>, ExportError> {
+ if images.is_empty() {
+ return Ok(Vec::new());
+ }
+ let threads = std::thread::available_parallelism()
+ .map(|n| n.get())
+ .unwrap_or(1)
+ .clamp(1, 16)
+ .min(images.len());
+ let chunk = images.len().div_ceil(threads);
+ let render_one = |image: &ImageInfo| -> Result, ExportError> {
+ let keep = keep_for(image.world, 16.0 / image.ppu, extent_of);
+ let rgba = scene.png(&image.view(), STROKE_PX, fonts, Background::White, keep)?;
+ to_rgb(&rgba)
+ };
+ let results: Vec>, ExportError>> = std::thread::scope(|scope| {
+ let handles: Vec<_> = images
+ .chunks(chunk)
+ .map(|group| {
+ let render_one = &render_one;
+ scope.spawn(move || {
+ group
+ .iter()
+ .map(|image| render_one(image))
+ .collect::, _>>()
+ })
+ })
+ .collect();
+ handles
+ .into_iter()
+ .map(|h| {
+ h.join().unwrap_or_else(|_| {
+ Err(ExportError::Render(PngError::RenderPanic(
+ "a tile rendering thread panicked".to_string(),
+ )))
+ })
+ })
+ .collect()
+ });
+ let mut out = Vec::with_capacity(images.len());
+ for group in results {
+ out.extend(group?);
+ }
+ Ok(out)
+}
+
+/// A sidecar's cap: its compact JSON never exceeds this.
+const SIDECAR_LIMIT: usize = 32 * 1024;
+
+/// The most rows of one kind a sidecar builds before the shrink loop even
+/// looks at it. The shortest row a group can write is about 25 bytes, so
+/// nothing beyond this could ever fit in [`SIDECAR_LIMIT`]; cutting here is
+/// the same cut the loop would make, reported the same way.
+const MAX_SIDECAR_ROWS: usize = SIDECAR_LIMIT / 20;
+
+/// The records a sidecar looks at, by kind.
+pub(crate) struct OnTiles<'a> {
+ pub(crate) texts: &'a [Record],
+ pub(crate) dims: &'a [Record],
+ pub(crate) blocks: &'a [Record],
+ pub(crate) regions: &'a [Record],
+ pub(crate) geometry: &'a [Record],
+}
+
+/// The sidecar of one written tile: where it is, how its pixels map to the
+/// world, its written neighbours, parent and children, and the records on
+/// it as positional rows (their columns named beside them), cut to fit
+/// [`SIDECAR_LIMIT`] with `counts` and `geometry_by_kind` staying exact.
+pub(crate) fn sidecar(
+ img: &ImageInfo,
+ tile: &Tile,
+ tiles: &[Tile],
+ profile: &Profile,
+ records: &OnTiles<'_>,
+ rounder: &Rounder,
+) -> Value {
+ // Only tiles that were *written*: an empty tile gets no .png and no
+ // .json, so a reader panning by `neighbors` must not be sent to one.
+ // tiles.json still lists the empty ones, with their reason.
+ let find = |z: u32, row: i64, col: i64| -> Option {
+ if row < 0 || col < 0 {
+ return None;
+ }
+ tiles
+ .iter()
+ .find(|t| t.z == z && i64::from(t.row) == row && i64::from(t.col) == col && !t.empty)
+ .map(|t| t.id.clone())
+ };
+ let (r, c) = (i64::from(tile.row), i64::from(tile.col));
+ let neighbors = json!({
+ "n": find(tile.z, r - 1, c),
+ "s": find(tile.z, r + 1, c),
+ "w": find(tile.z, r, c - 1),
+ "e": find(tile.z, r, c + 1),
+ });
+ let centre = Point2D {
+ x: (tile.world.min_x + tile.world.max_x) / 2.0,
+ y: (tile.world.min_y + tile.world.max_y) / 2.0,
+ };
+ let parent = tiles
+ .iter()
+ .find(|t| {
+ t.z + 1 == tile.z
+ && !t.empty
+ && t.world.min_x <= centre.x
+ && centre.x <= t.world.max_x
+ && t.world.min_y <= centre.y
+ && centre.y <= t.world.max_y
+ })
+ .map(|t| t.id.clone());
+ let children: Vec = tiles
+ .iter()
+ .filter(|t| t.z == tile.z + 1 && t.world.intersects(&tile.world) && !t.empty)
+ .map(|t| t.id.clone())
+ .collect();
+ fn on_tile<'a>(records: &'a [Record], world: &Rect) -> Vec<&'a Record> {
+ records
+ .iter()
+ .filter(|rec| rec.bbox.intersects(world))
+ .collect()
+ }
+ let truncate = |s: &str| -> String {
+ if s.chars().count() > 24 {
+ let cut: String = s.chars().take(24).collect();
+ format!("{cut}...")
+ } else {
+ s.to_string()
+ }
+ };
+ let (on_texts, on_dims, on_blocks, on_regions, on_geometry) = (
+ on_tile(records.texts, &tile.world),
+ on_tile(records.dims, &tile.world),
+ on_tile(records.blocks, &tile.world),
+ on_tile(records.regions, &tile.world),
+ on_tile(records.geometry, &tile.world),
+ );
+ let rows = |on: &[&Record], third: &dyn Fn(&Record) -> Vec| -> Vec