This stage also exists as a plain-language page with diagrams.
Stage 13 — Emit
Turns settled geometry, fills and stacking order into SVG text — the only stage that decides how a number is written down, not what it means.
Source: crates/inkvec-cli/src/emit.rs (emit_color, in nine stages) with
emit/winding.rs (ring direction), crates/inkvec-cli/src/pathdata.rs (path data),
primitive.rs (circle, ellipse and rectangle elements), rings.rs (nesting),
crates/inkvec-cli/src/post.rs (output options), and inkvec_svgmin::compact for --minify.
Entry points: emit_color() (crates/inkvec-cli/src/emit.rs:164); post-processing via
post_process() (crates/inkvec-cli/src/post.rs:451).
Pipeline position: after fill assignment (stage mark "fills",
crates/inkvec-cli/src/pipeline.rs:396), through the stage mark "emit"
(crates/inkvec-cli/src/pipeline.rs:445); post_process then runs separately, outside the
timed pipeline, just before the file is written.
Crate header, crates/inkvec-cli/src/lib.rs:6-21, gives the whole pipeline shape:
image ─▶ intake ─▶ trace ─▶ fit ─▶ repair ─▶ emit ─▶ post ─▶ SVG
" emit —
emit: fitted geometry to SVG text. * post* —post: viewBox, margin, background knock-out, minification.The objective is the same at every stage — squared residual against the image plus
lambdaper parameter written — so a stage only keeps what it can pay for."
What problem this solves
By the time this stage runs, every decision about the image has been made: curves, fills,
holes and stacking order are settled. What remains is turning that into bytes, and the module
header (emit.rs:30-47) names the two things that make that more than a neutral step:
" Coordinates are written to [
crate::pathdata::EMIT_DECIMALS], not to--precision. The geometry arrives good to hundredths of a pixel and rounding it to tenths threw away 7% of the fidelity on the full corpus, which is more than most of the fitter earns. [...] * Same-coloured siblings become one compound path.* An artist draws a letter and its counter as one path with a hole; emitting them as two costs a shape and leaves a seam along the shared edge. What a compound path paints is decided by parity -- a point inside an odd number of its rings is painted -- and the parity rules in [crate::rings] and [stack_faces] are what make the rings right. Each ring is then wound by its nesting depth ([winding]), so the defaultnonzerofill rule paints exactly that parity and nofill-ruleis written: fonts, cutters and old Android read the same shape a browser does. Until 2026-10 the paths saidfill-rule="evenodd"instead, and 30% of the screen set's files filled their holes back in for any consumer that ignored it."
Inputs and outputs
emit_color takes a ColorDoc (emit.rs:76-116): the rings of each face as walks over
shared edges, every fitted edge and whole-edge primitive, every face's fill model, the
palette and per-face colour index, per-face clear/opacity, alpha ramps and native fades,
optional recovered layers, the matte, and the raster size; and an EmitOptions
(emit.rs:127-144): --cutout, native transparency, --no-background, --precision, and
the harmonize settings. Output is a String — the complete <svg>...</svg> document, viewBox
-0.5 -0.5 w h, before post_process. The colour pipeline calls it once per candidate
document (flat, and with translucent layers when there are any); the bilevel pipeline calls
emit_bilevel (emit.rs:1365).
How it works
emit_color runs in stages, each a function with its own reasons (emit.rs:12-28):
nesting (rings::nesting), stacking (stack_faces), harmonizing, paint (face_paint), paint
order and names (paint_tree, face_ids), strokes (annulus_strokes), writing (Writer,
with same-coloured siblings merged into one compound path), seams (seam_overrides), and
layers (write_layers).
Before the writer: --editability (crates/inkvec-cli/src/editable.rs)
With --editability, finish_color (pipeline.rs) hands every fitted edge to
editable::edit_all after repair, mirrors and fills and before emit_color, so nothing the
writer does can re-break what it locked. Every pass is held to one test: sample the edited
ring at 8 points per segment and require each sample to lie within 3·σ_j + EDIT_SLACK
(0.5 px) of some measured point (MIRROR_SLACK, 1.5 px, for a mirror lock), one-sided. A
change that fails it is undone, one join, node or ring at a time. The passes, in the order
edit_all runs them:
- Arcs as cubics (
editable/curves.rs,pass_arcs). Every circular arc is written as the cubics a drawing tool writes: cut at each angle that is a whole quarter turn inside it, so every cut is an x or y extremum with both handles exactly on an axis, each piece the standard approximation with handles4/3·tan(Δ/4)·rlong (0.5523·r for a quarter; Riškus 2006, Information Technology and Control 35(4), whose largest radial error with that constant is 2.73e-4·r). A cut withinARC_CUT_GAP_PX(1 px, along the arc) of the arc's end is not made: the end is then all but at the extremum, and the sliver the cut would leave has handles too short for two decimals to point (a 0.4 px piece onmaterial-icons/dining). The piece beside it then turns by up to 112.5° (the gap is capped at 22.5°), with a radial error under 1.1e-3·r. The fit writes arcs asA(19 % of the trace's segments at 512 px on white, against the artists' 4 %; the artists write 67 % cubics); an editor shows anAas two nodes with no handles, wherever on the circle they fall. A stroke's centreline gets the same conversion when it is written (ribbons.rs,arcs_as_cubics); at 1.1e-3·r it needs no guard. - Runs re-noded at extrema (
pass_extrema):inkvec_fit::extrema::anchor_extrema(stage 11, Nodes at extrema) withEDIT_PREFERENCE: 3 parameters credited per anchored node instead of the fit's 1, and the written count free to change, so a segment that spans an extremum gains a node there. Kept only if the ring still guards. These two rewrite segments, so they run before anything that reads a node or a handle. - Mirror lock, then node alignment (
pass_mirror,pass_nodes): the passes that move nodes, before any handle is pointed. A node that alignment moves now carries its handles, as in an editor; left behind, a handle a pixel long turned by up to 15° for a 0.3 px move and a smooth node became a kink (material-icons/dining: kinked 0 → 0.18 from this pass alone). - Collinear lines merged (
pass_collinear). Node alignment lines nodes up on shared coordinates and mirror lock splits a line where its axis crosses it; both leave a node in the middle of a straight run, which artists do not draw (the battery'scollinearwas 15 % further from the artist's file under--editabilitythan without it). Two lines meeting at a turn of at mostCOLLINEAR_DEG(0.5°, the battery's own threshold) are written as one, the closing join of a closed ring included. Only the merged line is judged, against the two it replaces (it may stray no further than they did, or thanEDIT_SLACK): judged on the whole ring, a mirror-locked ring, held to the widerMIRROR_SLACK, never lost the node its lock had put on the axis. - G1 within a ring (
pass_g1). Every join that turns by at mostKINK_DEG(30°) is made smooth: between two cubics both handles turn onto the bisector, and next to a line the cubic's handle turns onto the line's direction. 30° is where the artists' own joins between curves separate: smooth (within 3°) or a corner of more than 30°, with only 8.6 % between (R3.1 ofdocs/theory/chain-representation.md). The pass used to stop atG1_SNAP_DEG(3°), which only made exact what was already smooth to the eye, and never looked at a join next to a line. It also turned the wrong handle, since it was written: the incoming cubic's first control point, at its far end, instead of the one at the join, so a join it "smoothed" kept its kink and the far end gained one (material-icons/dining: kinked 0.18 → 0.45 from this pass, 0 now). - Handle snaps (
pass_handles), per node: onto an axis within 2.5°, else onto the 45° diagonal within 1.5°. A smooth node's two handles snap together or not at all; snapping them one at a time turned one handle of a pair whose direction sat on either side of the window's edge and left a kink where there was none (the battery'skinkedwas 3.9 % further under--editability). A handle that leaves a line smoothly is not snapped, since the line fixes its direction. -
Equal handle lengths (
pass_equalize), as before. The per-ring passes (4 to 7) see a boundary with no junction on it as closed. The planar map hands such a boundary over as one open edge that ends where it starts, at a point of its own choosing, so its closing join was a join no pass ever looked at: neither these, which treat an open ring's ends as ends, nor the junction pass, which needs two edges.isolated_loopsfinds the edges that end where they start at a point no other edge ends at; they are flagged closed for passes 4 to 7 and the flag is put back after them. -
Joins at junctions (
editable/junctions.rs,pass_junctions), last. The other passes see one edge at a time, between two junctions, so they never see the join where a face's outline passes from one edge to the next. That is where layered art shows: a shape painted over another leaves the lower one's visible outline with a T-junction on each side, and the upper one's outline runs smoothly through both, but its two edges were fitted apart and meet at a kink. The pass walks each face's rings and turns the handles either side of every join between two edges that turns by at mostJUNCTION_G1_DEG(30°) onto one direction: a line's own direction when one side is a line; else the side that the handle pass left exactly on an axis, when the other is not, so the snap survives; else the bisector. No node moves, so the junction stays shared and no seam opens; both edges must still guard.
The path grammar actually emitted
Four serialisers exist:
| function | file:line | used for |
|---|---|---|
fmt_fitted |
pathdata.rs:60 |
a single open or closed stroke (run_strokes) |
fmt_path |
pathdata.rs:136 |
a plain point sequence |
fmt_ring / fmt_ring_with |
pathdata.rs:152, :164-279 |
a face ring assembled from shared planar-map edges (fmt_ring_with takes the seam pass's replacement edges) |
primitive_d |
primitive.rs:46 |
circle/ellipse/rounded-rect/rect primitives written as path data |
Commands the emitter writes:
M— once per subpath, at its start.L— forSegment::Line, and inside the rect/rounded-rect primitives' straight edges.C— forSegment::Cubic, when theSshorthand test (below) fails.S— the smooth-cubic shorthand; see below.A— forSegment::Arc, and for the arcs of a circle, ellipse or rounded rectangle written as path data.Z— closes a subpath;fmt_ring_withonly writes a ring once it has at least two segments (pathdata.rs:275-278).HandVare never written by the emitter.--minifyrespells the finished paths (see below), and its writer usesH,V, relative commands and dropped repeats where they say the same thing in fewer bytes.
fmt_fitted writes the two arc flags space-separated (A rx,ry phi large sweep x,y,
pathdata.rs:114), fmt_ring_with comma-separated (:258) — both are legal SVG. Arc rotation
(phi) is always written at a fixed 3 decimals in degrees, independent of the coordinate
precision. A one-segment ring (a walk out and straight back) is discarded entirely;
fmt_path refuses fewer than 3 points outright (pathdata.rs:136-139).
Coordinate precision — the headline fact
pub(crate) const EMIT_DECIMALS: usize = 2; (pathdata.rs:44). Its doc comment
(pathdata.rs:20-43) is the single most load-bearing piece of prose in this stage:
"This used to be derived from
precision, which at the default of 0.1 wrote one decimal and so rounded every coordinate to a tenth of a pixel. That was throwing the geometry away. Displacing the ground truth by a known sub-pixel amount and inverting the error it produces (via sub-pixel boundary calibration) measures the boundaries this tracer produces as accurate to 0.021 px on lucide and 0.060 px at worst -- between two and five times finer than the grid they were being written on, so up to half the emitted error was quantisation of an answer that was already right.A coordinate should not be rounded more coarsely than the geometry is accurate, and there is no reason to write it finer either. Two decimals, 0.01 px, is the first grid below the measured accuracy; the full 980-icon devset agrees, and stops agreeing immediately afterwards, which is what a bound being reached looks like:
1 decimal objective 0.4922 2 decimals objective 0.4601 3 decimals objective 0.4599 -- a fortieth of the gain, for another digit everywhereEvery family improves at two decimals, material-icons by 40 % and simple-icons by 16 %, and the parameter count against the artist does not move at all: this buys accuracy with digits, not with shapes."
Why quantising an accurate answer is pure loss: the fitted geometry (stage 11) is accurate to roughly 0.02-0.06 px. Rounding to 0.1 px introduces a quantisation error up to 0.05 px in the worst case — comparable to or larger than the geometric error itself — for no benefit. Two decimals sits below the measured accuracy floor; a third buys almost nothing, which is exactly the pattern the objective numbers above show.
Git provenance for the "7.2%" figure. Commit ebc7534, "Write coordinates to 0.01 px, not
0.1 px":
"Separating rounding from the segment price on the screen set: as shipped, 1 decimal 0.4760; finer price, 1 decimal 0.4790 (worse); 2 decimals 0.4451; 3 decimals 0.4449 (a fortieth more, for a digit). The whole gain is the rounding, and a finer price with the old rounding makes the corpus worse, so this is the emitter's bound and not the fitter's... Full devset against master, with the merge-distance change: full 980 0.4960 -> 0.4601 (-7.2%), held_a 156 0.4979 -> 0.4661 (-6.4%), held_b 156 0.5002 -> 0.4570 (-8.6%). 874 icons better, 105 worse, 1 unchanged. Every family improves, material-icons by 40%. Parameters against the artist do not move: this buys accuracy with digits, not shapes. Cost is 12.5% more bytes; the 95th-percentile trace time improves slightly."
The -7.2% figure is the full 980-icon devset, measured together with an unrelated merge-distance change in the same commit; the isolated screen-set effect of the rounding change alone is 0.4760 to 0.4451.
The accessor deliberately ignores its precision argument (pathdata.rs:46-51):
pub(crate) fn emit_decimals(_precision: f64) -> usize { inkvec_core::env::count("INKVEC_EMIT_DECIMALS").unwrap_or(EMIT_DECIMALS) }
--precision (default 0.1) still sets lambda for the MDL objective (stage 11); it does
not touch the number of emitted digits. Precision comes from Rust's {:.*} runtime-width
formatting; the explicit rounding helpers are the S-shorthand test (below) and the
rect/rounded-rect corner rounding in primitive.rs.
The S shorthand — tested in rounded space, and why
Two implementations exist, with two different tolerances.
fmt_ring_with (the production ring path) tests in the space the reader will
reconstruct from, not the fitter's raw floats (pathdata.rs:195-226):
"
Srestates a cubic whose first control point is the reflection of the previous one's second -- the same curve in two fewer numbers, which is whatmerge::snap_smooth_joinsarranges and what artists write (60% of their smooth cubic joins are exactly this).Tested on the rounded values, not the floats: those are what the reader gets, and a reflection that holds only before rounding would decode to a slightly different curve than the one that was fitted."
let r = |v: f64| { let m = 10f64.powi(decimals as i32); (v * m).round() / m }; let (wx, wy) = (2.0 * r(pp3.x) - r(pc2.x), 2.0 * r(pp3.y) - r(pc2.y)); let tol = 10f64.powi(-(decimals as i32)); if (a.x - wx).abs() < tol && (a.y - wy).abs() < tol { // write S ... }
prev_c2 carries (pc2, pp3) — the previous cubic's second control point and its endpoint —
across edge boundaries within a ring, since a ring is several edges' fits concatenated and a
smooth join can fall on the seam between two of them. It resets to None on any non-cubic
segment.
Why the test must be done in rounded space: the decoder reconstructs the reflected control
point as 2*r(pp3) - r(pc2) from the numbers actually written to the file. In general
round(2p - c) != 2*round(p) - round(c): rounding does not commute with the linear
reflection. Testing against wx, wy computed from the rounded pp3, pc2 — and comparing
them to the raw fitted a.x, a.y within one step of the emitted grid (tol = 10^-decimals,
0.01 px at the default) — matches what the SVG reader will actually reconstruct.
fmt_fitted (the stroke path) is the older, stricter form: it compares 2*pp3 - pc2
against c1 on raw floats, with a Euclidean tolerance 5e-4 px (pathdata.rs:57-58, code at
:76). This has no stated derivation, and the two paths can disagree on when a join is smooth
enough.
Provenance of the "60%" figure — commit 81959fd, "Offer the smooth cubic as a candidate,
opt-in": "of the joins between consecutive cubics that are smooth to within a thousandth of a
degree, 60% have equal handle lengths either side, the ratio's median being exactly 1.00 with
its whole interquartile range at 1.00." The fitter-side producer, merge::snap_smooth_joins,
is compiled only into research builds (behind INKVEC_G1, see 11-fitting.md), because it
does not pay on the objective ("objective 0.4005 -> 0.4019, fourteen icons better and
seventy-four worse", same commit). The emitter's S detection is unconditional — a curve
that happens to come out smooth for any reason gets the short form.
Compound paths, wound by nesting depth
Writer::emit_level (emit.rs:1087-1098) writes the faces of one nesting level:
"Siblings are disjoint by construction -- every pixel belongs to one face -- so all the siblings of one flat colour can be one compound path under even-odd, which is how an artist draws them: a whole word is one
<path>, not one per letter. Before this the tracer wrote one element per connected face and came back with 3.5x the artist's path count on a detailed wordmark at the same colour error. Primitives stay their own element and gradient faces each own a gradient, so neither merges; nor does a harmonized<use>. The merged path takes the first member's place in the paint order and its id; every member's children follow it."
The merge condition has gates, no numeric threshold: a face must not be a primitive element,
a stroke or a harmonized <use>; its fill must be a flat hex colour (a gradient's url(#...)
never merges); and its fill and fill-opacity must match another sibling's exactly.
Which rule paints the holes. A compound path's holes — a letter's counter, a face punched
out of the faces under it (stack_faces) — are decided by parity: a point inside an odd
number of the path's rings is painted. Until 2026-10 every path carried
fill-rule="evenodd" to say so, and its rings ran in whatever direction the planar map's walk
gave them. Under SVG's default nonzero rule — the only rule of TrueType and OpenType
outlines, Fontello and the icon-font chain, many cutter and CAD importers, and Android vector
drawables before API 24 — such a hole fills back in; on the 246-icon screen set 73 files
(30%) rendered differently under nonzero (emit/winding.rs:6-13).
Now every path is passed through winding::for_nonzero (emit/winding.rs:104-119) when it is
written (Writer::path_element, emit.rs:1072-1084). The method (emit/winding.rs:15-34):
for rings that do not cross each other, a point inside k nested rings is crossed once by
each, so if the ring at depth d runs the way (−1)^d says, the winding number is
1 − 1 + 1 − … over k terms — 1 for odd k, 0 for even, the even-odd answer at every
point. So the two rules agree and no fill-rule is written. In the SVG's y-down coordinates a
ring at even depth runs with a negative shoelace sum, the way the planar map already walks an
outline (2,107 of 2,123 outer rings on the screen set), so the convention reverses 154 rings
where the opposite would reverse 2,152. Per compound path (emit/winding.rs:36-50):
- Read the
das the emitters write it (absoluteM,L,C,S,A,Z, numbers kept as text). Anything else and the path is returned unread, withfill-rule="evenodd"(EVENODD,emit/winding.rs:102): the fallback is the old output, never a wrong picture. On the tracer's own output this does not happen. - Measure each ring on the polygon that follows its curves (
rings::ring_points): its signed area and up to five interior probes (rings::interior_probes). - Depth parity: the parity of all ray crossings with the larger rings' edges (a
row-bucketed index,
DepthIndex), the majority over the probes deciding. - Write each ring that runs the wrong way reversed and every other ring as its original
text, byte for byte. A reversed cubic swaps its control points; an arc keeps its radii,
rotation and large-arc flag with the sweep flag flipped, which by SVG 1.1 F.6.5 selects the
same centre; an
Sis expanded with its reflected control point computed in exact decimal arithmetic and written asSagain only where that reflection holds exactly.
The same pass winds the recovered --layers paths (write_layers), the harmonized symbols
(harmonize.rs:133) and the --monochrome and bilevel documents (mono.rs:540); a <use>
of a harmonized symbol carries no fill-rule, since the symbol itself was wound
(emit.rs:1030-1031).
Measured (emit/winding.rs:66-80; the 246-icon screen set at 512 px, against v0.2.4 rendered
under evenodd): with evenodd forced back on, the wound files render identically in resvg,
246 of 246 — the reversal moves nothing; under the default rule 237 of 246 are identical, and
the other 9 differ in 1 to 100 pixels of 262,144, all where two rings of one path overlap by a
sliver (two same-coloured siblings whose fitted outlines cross where they touch): even-odd cut
the overlap out, the wound path paints it, closer to the artist's file in 6 of the 9 (the
gate: 3 icons better, 1 worse by 0.001 dE00). No ring direction can make two overlapping rings
agree under both rules. Chromium agrees on 215 of 246, since Skia's anti-aliasing depends on
edge direction (12 files differ by at most 10 pixels even with evenodd forced). Read under
nonzero, v0.2.4's files differed from themselves in 85 of 246 (2.95 million pixels). The
code labels its sources: "Method from" W3C SVG 1.1 (Second Edition) §11.3 (fill-rule) and
Appendix F.6; "Inspired by" the TrueType Reference Manual, ch. 1 (contour direction under the
non-zero rule) and Fontello's icon-font advice; the depth-parity ray cast is the
crossing-number test of rings::point_in_ring summed over rings.
The measured figures for the merge itself are in commit history. Commit b13bed6, "Emit
the pieces of one colour as one compound path":
"The tracer wrote one
<path>per connected face; an artist draws all the disconnected pieces of one colour as one compound path, so a word is one path and not one per letter. On a detailed wordmark that was 156 paths against the artist's 44 at the same colour error... Siblings are disjoint by construction, so the union is exact. The only pixels that change are the anti-aliasing seams between same-colour neighbours, which disappear: at most 0.007% of pixels, in components of 17 pixels or fewer, colour error equal or slightly better. Four detailed logos at 2048: 311 -> 45 paths (artist 434), 114 -> 27 (298), 227 -> 40 (173), 156 -> 51 (44). Twenty brands at 1024: mean path ratio 1.85x -> 0.75x, logos over 1.5x from nine to two. Gate PASS."
The "under 1.5x author paths" project rule is recorded only in that commit narrative, not
enforced as a hard gate: bench/ci_gate.py's MARGINS (ci_gate.py:138) caps the
parameter-vs-artist ratio's regression at 3% relative, judged at the one-sided 95% upper
bound of a paired bootstrap, which is a change-detection gate, not an absolute ceiling.
Gradient and fill emission
Each face's fill attribute comes from face_fill (emit.rs:722-786), in order of precedence:
under a recovered layer, the ground colour; a fade traced natively, written by
gradient::fade_to_svg (id f{i}); an alpha ramp, written as the gradient an editor would
use — one colour, two stop-opacity values, along the axis the fade was measured to run (id
a{i}, endpoints at two decimals and opacities at three, emit.rs:756); its fill model,
delegated to gradient::fill_to_svg (crates/inkvec-trace/src/gradient/svg.rs:74); and with
no fill model, its palette ink, else black. A translucent face's flat colour is "un-matted"
before writing, since it was measured over the compositing matte. fill_to_svg returns a
(defs fragment, fill attribute) pair: FillModel::Flat needs no defs;
FillModel::Linear/Radial write a <linearGradient>/<radialGradient> with
gradientUnits="userSpaceOnUse". No empty <defs> block is written when there is nothing to
put in it (emit.rs:259-263).
Which gradients reach this point is decided in the fills mark (below): a gradient whose
stops a viewer could not tell apart is painted flat first.
Where the alpha ramps come from, and which faces are fitted. The ramps are measured just
before writing, inside the emit mark: face_transparency (pipeline.rs:1042) calls
alpha::face_alpha (crates/inkvec-cli/src/alpha.rs:942), which under --cutout tries, for
each face that is neither clear nor already one flat opacity, to fit a plane to its interior
alpha (fit_alpha_ramp, alpha.rs:171) and keeps it only when it fades by at least
RAMP_MIN_FADE = 0.15, with an RMS residual of at most RAMP_MAX_RESIDUAL = 0.06, over at
least RAMP_MIN_INTERIOR = 64 interior pixels (alpha.rs:88-94). Since 2026-09-30 the pass is
linear in the pixel count (face_alpha's "Passes", alpha.rs:913-924):
- one pass over each row's runs gathers every face's alpha statistics (
face_stats,alpha.rs:1113); each f64 sum receives the same terms in the same order as the per-pixel loop it replaced; - the clear and opacity verdicts come from those sums alone;
- a face goes to the fit only when
ramp_candidate(alpha.rs:1274) says the fit can return anything, and the candidates' interior pixels are gathered in one more pass (interior_pixels,alpha.rs:1298).
The skip is a proof, not a heuristic (alpha.rs:1232-1259): a face with fewer than 64 interior
pixels fails the fit's own first test, and a face whose interior alphas are all exactly 1.0
gets an exactly flat plane — the right-hand side of the normal equations is bit for bit the
matrix's first column, so Cramer's rule (solve3x3, alpha.rs:299) evaluates det(M_1) and
det(M_2) to exactly 0, and the fit returns None. On the research sets 96% of the 3,125
calls were such provable Nones, and none ever returned a ramp. The old whole-image fit is kept
as fit_alpha_ramp_scan (alpha/ramp_tests.rs:13). Citations, as the doc comments give them:
the run-based statistics "Method from" He, Chao & Suzuki 2008, "A Run-Based Two-Scan Labeling
Algorithm", IEEE TIP 17(5); the skip "Not from the literature: a proof that a least-squares fit
is degenerate", with "See also" He & Chao 2015.
Stage 9: the recovered layers, back to front
With --layers, write_layers (emit.rs:1318-1360) paints each recovered translucent layer
over everything as one compound path: the union of its faces, from the rings they had before
the ground under them was merged, wound by depth so the default rule paints exactly the union,
and one path rather than one per face so the translucent paint is composited once and no seam
appears where two pieces met. The layers are written back to front
(emit.rs:1327-1334): the decomposition's list is in peel order, frontmost first, because a
layer can only be taken off once nothing lies over it. Written in that order the frontmost
layer went down first and every layer behind it was composited over it: on
synthetic/stack_overlap the blue disc came out on top of the green one that covers it, dE00
0.019 -> 2.90 with --layers. Over a face both cover, the document now draws
a₀·C₀ + (1 − a₀)·(a₁·C₁ + (1 − a₁)·G), the stack the decomposition peeled; the ids keep the
peel index, layer-0 frontmost. Which layers are recovered at all (sRGB only, flat faces only,
and only when the stack reproduces every covered face within dE00 1.0) is
alpha::layers::recover_layers; see 01-intake.md.
--minify and --no-background
post_process (post.rs:435-463) composes the output options in a fixed order: background
knock-out, then either --minify (minify_svg, then compact_paths) or, without it, the
generator comment and metadata (annotate), then the margin.
knock_out_background (post.rs:151-242, for --no-background) removes the element that
paints the whole canvas. A <rect> is matched by its numbers: all four of its edges within
CANVAS_TOL = 0.25 px of the canvas's, because a fitted rectangle lands a hundredth of a pixel
or two off them. A <path> is matched by text: its outer ring must contain all four canvas
corners, written at the emitter's own decimal count (emit_decimals) — until 2026-09-08 those
digits were hard-coded and an INKVEC_EMIT_DECIMALS override made the match fail silently
(post.rs:161-167). Only the first matching element is removed; children of that face remain;
nothing is removed when nothing matches. It runs before minify, which changes how the numbers
are written. Under --monochrome it stands aside, since each emitter then leaves the ground
out itself.
minify_svg (post.rs:244-259) strips every id="..." that nothing references (a substring
test for #id, so a false positive costs bytes, never a broken reference), removes <g>
wrappers left with no attributes, and strips trailing zeros from decimal numbers (12.50 ->
12.5, 3.00 -> 3), guarding hex colours and URLs.
compact_paths (post.rs:465-491) then hands every d to inkvec_svgmin::compact
(crates/inkvec-svgmin/src/lib.rs:117-131): the same numbers, written in the fewest bytes —
relative where that is shorter, repeated letters and needless separators dropped, H/V/S
where they say the same thing — with nothing rounded and nothing moved. Arcs stay arcs
since 2026-10: the path is read as written (path::parse_d_written,
crates/inkvec-svgmin/src/path.rs:226-252, "Method from" SVG 1.1 §8.3 and Appendix F.6, the
command resolution following svgtypes' SimplifyingPathParser minus its arc-to-cubic step),
where the fitter's reading turned each arc into the cubics that approximate it and made every
path holding an arc too long to replace. Measured on the 246-icon screen set (2026-10-02):
--minify writes 478,421 bytes where the default writes 710,367, every file pixel-identical
to the default at 512 px; before the arcs were kept it stopped at 541,427; SVGO 4.1's default
preset, which rounds, takes a further 1.1% off (post.rs:467-477). Rounding is deliberately
left out ("ten points left on the table on purpose": two decimals would take 18% instead of
8.5% but move pixels on gradient-heavy traces); inkvec-svgmin --decimals is where precision
is spent for bytes. If the writer fails to parse the document, or its result is not strictly
shorter, the input is returned as it was.
viewBox, margin, and fit_viewbox
The emitted header is <svg ... viewBox="-0.5 -0.5 {w} {h}" width="{w}" height="{h}">. The
origin is (-0.5, -0.5) because coordinates are pixel centres — pixel (0,0)'s centre is
at 0,0, so the canvas spans -0.5 to w-0.5.
post::retarget (post.rs:63-90) rewrites only the root width/height (leaving viewBox
untouched) to make a document traced at one size render at another — used whenever the raster
was resampled or capped (--max-dim). post::with_margin (post.rs:344-406) grows the
viewBox by margin · max(w, h) on every edge and scales the presented size in proportion,
writing all six numbers at two decimals; a document whose root is not the emitter's header is
left as it was.
fit_viewbox is not part of the Rust emitter. It lives in the Python benchmark harness,
bench/inkvec_bench/render.py:101, and exists to make a non-square SVG render at a requested
square size without Lanczos-stretching it — its docstring records the regression this fixed:
"a 1984x400 logo asked for at 512x512 came back 512x104 and was then stretched 4.9x vertically
with Lanczos [...] The tracer then found a phantom ink in the soft ramps and the lettering came
out wrong (2026-09-05)."
The fills and emit stage marks
Both stage marks are inside finish_color (pipeline.rs:350), timed by a Stopwatch
(crates/inkvec-trace/src/lib.rs:1228) that prints only under INKVEC_TIMING.
fills (pipeline.rs:394-396) times the mirror-symmetry reconciliation after repair
(apply_mirrors, pipeline.rs:947: one boundary's fit reflected onto its mirror) and
final_fills (pipeline.rs:985-1019): --no-gradients paints every face its palette ink, and
otherwise demote_imperceptible_gradient (crates/inkvec-cli/src/pipeline/demote.rs:38)
paints flat a gradient no viewer could see. The test is the profile's colour range, the
largest OKLab distance between any two of its stops, ends and interior stops alike, against
JND = 0.02; a demoted gradient is painted the mean of its two ends. Until 2026-10 only the two
ends were compared, which painted flat a light-dark-light shading — a highlight across a cheek,
a crease in a sleeve — whose contrast is all in the middle: on the 168 corpus icons with artist
gradients, 18 of the 49 demoted gradient faces had interior stops, and keeping the ones whose
stops differ made 7 icons better and none worse (pipeline/demote.rs:20-26). With no interior stops the
test is the old end-to-end one exactly. The gradient fitting itself happens earlier, inside
inkvec_trace, and is charged to trace_total. The demotion is "Not from the literature: a
fix to the guard's own definition", with "See also" Ottosson 2020 (OKLab).
emit (pipeline.rs:397-446) covers alpha-layer recovery (alpha::recover_layers), the
per-face transparency above, and two competing documents, priced against each other
(write_colour, pipeline.rs:1132-1141):
"Both forms of the document, costed against each other.
A layer is only worth having if it says the same thing in fewer marks. It repaints the faces it covers with the ground and composites itself over them, so the image is identical either way and the whole decision is a parameter count — the same test every fill model and every arc has to pass, with the residual term equal on both sides. Where the layer does not pay, the flat form is what is written."
The flat document is always emitted; if layers were recovered, a second document is emitted
with the covered faces merged into their ground (merge_map, pipeline.rs:1111), and the two
are priced by counting shape elements and bytes in each:
let pays = pl < pf && bl <= bf; // fewer shapes AND not more bytes
(pipeline.rs:1222). Whichever wins becomes the output.
Constants and thresholds
| name | file:line | value | controls | derivation |
|---|---|---|---|---|
EMIT_DECIMALS |
pathdata.rs:44 |
2 | coordinate decimal places | fully derived — see Coordinate precision above |
MIN_RING_AREA |
rings.rs:34 |
0.25 px² | smallest ring area worth emitting | motivated (far below a pixel so thin features survive); the 0.25 not swept |
S-shorthand tolerance (fmt_ring_with) |
pathdata.rs:216 |
10^-decimals (0.01 px default) |
rounded-space reflection test | derived from the emitted grid itself |
S-shorthand tolerance (fmt_fitted) |
pathdata.rs:76 |
5e-4 px, raw-space Euclidean |
stroke-path reflection test | no stated derivation |
| ring segment minimum | pathdata.rs:275 |
2 | a ring is discarded below this | it encloses nothing |
| path point minimum | pathdata.rs:137 |
3 | fmt_path early return |
degenerate-polygon guard |
| arc rotation precision | pathdata.rs:114, :258, :338 |
3 decimals (fixed) | arc phi formatting |
no stated derivation |
| rounded-rect radius floor | primitive.rs:76, :281 |
1e-4 |
below this, written as a plain rect | no stated derivation |
| ellipse rotation floor | primitive.rs:258 |
1e-3 |
below this, no transform written |
no stated derivation |
| alpha-ramp endpoint precision | emit.rs:756 |
2 decimals (fixed) | independent of EMIT_DECIMALS |
no stated derivation |
| opacity precision | emit.rs:756, :787, :793 |
3 decimals | fill-opacity/stop-opacity |
no stated derivation |
INKVEC_EMIT_DECIMALS |
pathdata.rs:50 |
env override | overrides EMIT_DECIMALS |
the mechanism used to isolate rounding from the segment price in the 7.2% measurement |
EVENODD |
emit/winding.rs:102 |
fill-rule="evenodd" |
written only on a d the winding pass cannot read |
the old output as a fallback |
CANVAS_TOL |
post.rs:186 |
0.25 px | --no-background rect match |
motivated (a fitted canvas rect lands 0.01-0.02 px off) |
| margin viewBox precision | post.rs:393 |
2 decimals | --margin growth |
no stated derivation |
JND |
pipeline/demote.rs:41 |
0.02 (OKLab) | gradient demotion | "a conservative multiple of a just-noticeable difference" |
LAYER_MAX_DE00 |
alpha/layers.rs:32 |
1.0 dE00 | --layers reproduction guard |
about one just-noticeable difference, the tolerance the merge after it already spends |
ci_gate.py ratio margin |
bench/ci_gate.py:138 |
3% relative, at the one-sided 95% upper bound | regression gate on parameter count vs artist | change detection, not the 1.5x absolute rule cited in commit history |
Failure modes and edge cases
- Zero-area rings are dropped outright. The planar map can produce faces one pixel wide
whose boundary walks out along a chain and straight back; these were emitted as paths that
"paint nothing at any resolution and cost coordinates, an id, and a line in the document a
person has to read past" (
rings.rs:107-116) — "twenty-seven of them on a plain green circle." - Rounding a rectangle's origin and size independently moves the far edge more than the
near one. The fix rounds the two edges and derives width as their difference
(
primitive.rs:270-276): "how a rectangle centred exactly on a mirror came out a tenth of a pixel wider on one side than the other." - Punching a fitted ring instead of the primitive it was fitted from leaves a visible
seam — measured on
material-icons/qr_code, dE00 0.060 -> 0.164 (primitive.rs:24-28). - Parity means punching a transparent face out of its painted ancestors only up to the first
transparent one. A second ring inside an already-punched hole flips it back solid — the
counter of a heart inside a page inside a book cover came out solid black, dE00 0.19 -> 3.33
on
lucide/book-heart(emit.rs:294-301). - Containment for cutting must be certain, not merely likely.
ring_inside's majority vote is right for paint-order stacking and wrong for deciding what to punch — a black wedge beside the head onnoto-emoji/emoji_u1f3cb_200d_2642, dE00 0.58 -> 1.78 (emit.rs:344-352). A punch needs every probe inside (strictly_inside). fill-opacityin a stacked document blends against whatever painted underneath it, not the page — the film frames onnoto-emoji/emoji_u1f39e, dE00 0.46 -> 1.43 (emit.rs:514-525).- A primitive element cannot carry a hole. A face that must show a hole through it is
written as a path even when its outline would otherwise fit a circle (
emit.rs:1051-1052). - Drawing every face's own holes (making paths self-contained so any face could drop
freely) was tried and reverted — parameters rose (the arrow 166 to 248) and DISTS regressed
on three of six probes, "because a ring contained inside another of the same face is not
reliably a hole, and punching it makes one anyway" (
emit.rs:170-175). - Two rings of one path that overlap by a sliver paint the overlap under
nonzerowhereevenoddcut it out; no ring direction can make the two rules agree there (9 of 246 screen icons at 512 px, 170 px in all; see the winding section). - Layers painted in peel order put the frontmost layer underneath (
synthetic/stack_overlap, dE00 0.019 -> 2.90); they are now written back to front. --no-backgroundis a match, and a no-op if it misses — it returns the input unchanged when no element matches; it must run before minify, which rewrites the numbers.
Environment overrides
Since the settings cleanup (CHANGELOG, 0.2.0, Changed) the engine reads its environment through one helper (inkvec_core::env): a switch is off when unset, empty or 0, and every variable is read once per process. Variables marked removed below are gone (their defaults are constants now); those marked research build are read only by a binary built with --features research. The full list, with what is left and why, is docs/internal/env-vars.md.
| variable | effect |
|---|---|
INKVEC_EMIT_DECIMALS |
overrides EMIT_DECIMALS (coordinate decimal places); --no-background's path match follows it |
INKVEC_ALPHADBG |
per-face emit debug dump (rings, areas, outer/clear/parent/drop, holes; emit.rs:636) |
INKVEC_TIMING |
enables the Stopwatch printout that surfaces the fills/emit marks |
Open questions
fmt_fitted'sS-shorthand test uses raw floats and a5e-4Euclidean tolerance, whilefmt_ring_withtests in rounded space against10^-decimals. The stroke and face-ring outputs can disagree on when a join is smooth enough, and the5e-4figure carries no derivation.- The "1.85x -> 0.75x" compound-path figure and the "under 1.5x" project rule live only in a
commit message (
b13bed6), not in any code comment or enforced gate. - Overlapping rings. Where two same-coloured siblings' fitted outlines cross by a sliver, the wound path paints the overlap (closer to the artist in 6 of 9 measured cases); the rings themselves still overlap, and no fill rule can hide that.
- Several formatting-precision constants (arc rotation at 3 decimals, opacity at 3 decimals,
alpha-ramp endpoints at 2 decimals independent of
EMIT_DECIMALS, the rounded-rect radius floor1e-4, the ellipse rotation floor1e-3) have no stated derivation and were not swept the way the headline coordinate precision was. MIN_RING_AREA = 0.25 px²and the ring segment minimum of 2 are motivated but not swept against the corpus the wayEMIT_DECIMALSwas.