This stage also exists as a plain-language page with diagrams.
Stage 11 — Curve fitting
Turns a measured boundary — a polyline with a per-point uncertainty — into the smallest description-length combination of lines, cubics, arcs and constrained primitives that still explains the measurement.
Source: crates/inkvec-fit/src/lib.rs, multimodel.rs, primitives.rs, curves.rs,
simple.rs, merge.rs
Entry point: optimal_multimodel() (crates/inkvec-fit/src/multimodel.rs:202), called
from crates/inkvec-cli/src/lib.rs:868 for the colour path and lib.rs:607 for strokes.
Pipeline position: after decode and symmetry enforcement (stages 9–10), before repair
(stage 12). Stage mark "fit_dp" (crates/inkvec-cli/src/lib.rs:899).
What problem this solves
Every earlier stage produces a measured boundary: a polyline of points, each with a
positional uncertainty sigma inherited from stage 2's coverage measurement and stage 7–8's
sub-pixel and boundary-solve refinement. This stage turns that polyline into an SVG path —
lines, cubics, arcs — choosing both the segmentation (where the breakpoints fall) and, per
segment, which primitive to spend parameters on.
A line-only alphabet has a hard floor no amount of segmentation quality can lift.
curves.rs:3-7, verbatim:
"The line-only alphabet has a hard floor that no amount of segmentation quality can lift. A circle approximated by chords within tolerance
eneeds aboutpi / sqrt(e / 2r)segments — 36 of them for a 46px radius at 0.1px — while four cubics track the same circle to about 0.02% error. The segmentation was already optimal; it was optimal over the wrong alphabet."
So this stage does two things at once: it picks where to break the boundary, and it picks what each piece is drawn with — under one objective, so the two decisions cannot fight each other.
Inputs and outputs
Input: an inkvec_core::Polyline — points plus a per-point sigma: Vec<f64> — and a
FitConfig.
FitConfig (lib.rs:47-61) has exactly two fields:
| field | meaning |
|---|---|
tau |
confidence multiplier on the per-point sigma; the default 2.0 "admits a chord that stays within ~2 standard deviations of every measurement" |
lambda |
cost per emitted parameter, in nats — the exchange rate between fidelity and description length |
Output: FittedPath (a sequence of curves::Segment: Line, Cubic, Arc) from
optimal_multimodel, or the richer MultimodelFit from optimal_multimodel_full
(multimodel.rs:187-199):
pub struct MultimodelFit { pub path: FittedPath, /// Indices into the source polyline chosen as vertices. For a closed input the /// first and last are the same index. pub vertices: Vec<usize>, /// Type of each segment `vertices[k] -> vertices[k+1]`. pub kinds: Vec<SegKind>, /// Value of the dynamic program's objective at the optimum, *before* the continuous /// refinement of `refine`. This is what the exhaustive test verifies. pub cost: f64, }
SegKind is Line, Cubic, or Arc (circular and elliptical arcs share the tag; only the
stored parameters differ). optimal_multimodel_capped / _capped_full add a max_span
argument — the point-span cap stage 12's repair uses; see 12-repair.md.
How it works
The objective, and where lambda comes from
The cost of a candidate description is
cost = 0.5 * chi2 + lambda * params
chi2 is the weighted sum of squared residuals against the measured points (each weighted
by its own 1/sigma^2); params is the count of numbers the description would write.
FitConfig::from_precision (lib.rs:63-81) derives lambda, verbatim:
"MDL measures description length in nats. A coordinate confined to a range
extentand stored to resolutionprecisioncarriesln(extent / precision)nats of information. For a 256px canvas written at 0.1px precision that isln(2560) ~ 7.85— not the 1.0 a first guess suggests, and the difference is roughly a factor of two in emitted segment count."
pub fn from_precision(extent: f64, precision: f64, tau: f64) -> Self { let ratio = (extent / precision.max(f64::MIN_POSITIVE)).max(std::f64::consts::E); Self { tau, lambda: ratio.ln() } }
The .max(std::f64::consts::E) floor means lambda is never below 1.0, however small
extent/precision gets. The default (lib.rs:83-88) is a 256 px canvas at 0.1 px output
precision, tau = 2.0.
In the shipping CLI, extent is the intake raster's max(width, height) in pixels, and
precision/tau come from --precision (default 0.1) and --tau (default 2.0)
(crates/inkvec-cli/src/args.rs:62-63, crates/inkvec-cli/src/lib.rs:374). The
content-units variant (crates/inkvec-cli/src/lib.rs:130-136) scales both precision and
lambda by a further factor s so that "lambda [stays] at ln(REF_EXTENT / precision)
whatever the extent" while the point-count term absorbs the scale.
At a 512 px intake with defaults: lambda = ln(5120) ≈ 8.541. At 2048 px: ln(20480) ≈
9.927. Note how little lambda moves for a 4x change in image size — this is the first
sign of the fact below.
The effective geometric tolerance — and why the square root matters
No line in the repo writes d = sigma * sqrt(2*lambda*k/n) as a formula — this is a
derivation, not a quotation — but every quantity in it is pinned to code: the cost function is
cost = 0.5 * chi2 + lambda * params (lib.rs:73-77), applied identically at the DP's
per-candidate comparisons (lib.rs:896,909, multimodel.rs:795,808,1310, primitives.rs:691)
and at merge's pairwise accept test (merge.rs:684-687, merge.rs:862); and chi2 is the sum
of squared residuals each weighted by 1/sigma^2 (lib.rs:137, chi2_line at lib.rs:162).
Given those two facts, the algebra below is exact, not speculative.
The accept/reject test used throughout this crate is: spend k extra parameters only if
0.5 * delta_chi2 < lambda * k (see e.g. merge.rs's repeated
0.5 * (chi2_a - chi2_b) < lambda * (params_a - params_b) pattern, and the DP's own
per-candidate cost comparison). Suppose a k-parameter-cheaper description differs from the
n-point-richer one by a roughly uniform positional deviation d over n points, each of
measured uncertainty sigma. Then delta_chi2 ~ n * d^2 / sigma^2, and the break-even point
is
0.5 * n * d^2 / sigma^2 = lambda * k d = sigma * sqrt(2 * lambda * k / n)
d is the effective geometric tolerance the objective is willing to accept in exchange for
k fewer parameters spread over n points — the practical answer to "how far can the curve
drift from the measurement before it costs more than it saves."
lambda enters under a square root. Doubling lambda (say, by moving precision from
0.1 to 0.01, a ten-fold tightening) widens d by only sqrt(2) ≈ 1.41. Combined with
lambda itself being a logarithm of extent/precision, the tolerance the fitter actually
uses is doubly damped against the two things a user might naively expect to control it
directly: image size and output precision. A ten-fold change in precision moves lambda
by ln(10) ≈ 2.30 — roughly 27% at lambda ≈ 8.5 — and the square root halves that again to
about 13% in d. This is the reason docs/DESIGN.md:872 records, of a lambda sweep, "that
range is lambda 8.76 to 5.36, a factor of 1.6, and far too narrow to say" — the geometric
behaviour of the fitter is genuinely flat across a wide range of plausible lambda values.
Anyone hunting for a quality regression by adjusting precision/lambda is pulling the
weakest lever in the system; sigma (the measurement model) and the per-primitive parameter
counts (PARAMS_LINE, PARAMS_CUBIC, …) move the output far more per unit of change.
The tau * sigma admissibility envelope — and a gap between the module doc and the code
The crate's top-of-file doc (lib.rs:13-19) frames admissibility as a geometric test:
"We ask whether every point is statistically consistent with the chord:
|d_k| <= tau * sigma_k. Where the boundary was well localized, sigma is small and we demand tightness; where it was faint, sigma is large and we simplify hard."
and describes the mechanism as "straightness by incremental cone intersection"
(lib.rs:28), implemented by DirectionCone (lib.rs:186-260), whose own doc explains the
incremental-cone-intersection algorithm in detail. This is no longer how the shipping
dynamic program tests admissibility. DirectionCone is constructed in exactly one
production function, is_admissible (lib.rs:398-412), whose own doc says: "Exposed because
an independent, obviously-correct implementation of the admissibility rule is what lets the
tests verify the fast path" — and its only caller outside tests is
crates/inkvec-fit/examples/lambda_sweep.rs:59, a reference greedy segmenter, not
optimal_multimodel.
The reason the cone left the DP is recorded as a correctness regression, lib.rs:322-329:
"An earlier version pruned with an angular cone on the chord direction instead. That was a correctness bug wearing an optimization's clothes: on a 49px straight edge the cone excluded the whole-edge segment, and the dynamic program dutifully returned the best of the remaining options — splitting straight edges at collinear points and returning 12 segments for a hexagon. A bound tied to the cost function cannot fail that way, because it discards only segments the cost function would have rejected anyway."
What actually gates admissibility today is the objective itself, expressed through tau in
four places: the tangent-window acceptance test in the tangent estimator
(tau2 = cfg.tau * cfg.tau, multimodel.rs:495, :2272), the primitive-fit acceptance gate
(primitives.rs:1341), and MAX_REDUCED_CHI2 = 4.0, documented as "tau² with the default
tau = 2" (primitives.rs:1078-1081). So tau still means the same thing — how many
standard deviations of the measurement a candidate is allowed to spend — but the module doc's
description of the mechanism is stale for the alphabet actually in use.
The dynamic program: optimal_multimodel
State. A bare vertex index into the (decimated, possibly opened) point sequence.
multimodel.rs:52-69, verbatim:
"The state is the vertex index alone. The tangent at each vertex comes from the polyline itself, estimated once, one-sidedly, before the program runs... This was chosen over the alternative of a
(vertex, tangent-bin)state because it is exact: there is no quantization, the objective is a sum of per-segment and per-vertex terms, and the dynamic program is verified against exhaustive enumeration of every segmentation and type assignment. The price is that the tangent at a join is estimated rather than optimized jointly with its two segments."
The tangent at each vertex is estimated one-sidedly — t- from a local quadratic fitted to
the points before the vertex, t+ likewise from the points after — because "a symmetric
window straddling a true corner returns the bisector, which is the wrong tangent for both
neighbouring segments." The window itself is the widest whose quadratic fit still satisfies
chi2/dof <= tau^2, so it is wide on smooth runs and narrow beside corners.
Transitions. For every ordered pair (i, j) with i < j <= min(i + max_span, n-1), up
to five candidate primitives are priced (multimodel.rs:1880-2003):
- Line — cost
0.5*chi2 + lambda*PARAMS_LINE + break_cost(t+_i, chord) + break_cost(chord, t-_j), plus the bow penalty (below). - G1 cubic — tangent directions inherited from the vertex estimator, only the two arm
lengths fitted; gated on
line_cost > cubic_floorso a cubic is only tried where a line is already expensive. - Free-tangent cubic — both end tangents fitted freely; off by default (see below).
- Circular arc — an O(1) algebraic (Kåsa) fit re-scored about the drawn circle.
- Elliptical arc — a heavier, non-O(1) conic fit tried only every 16th candidate length.
Recurrence.
best[0] = 0
base(i) = best[i] + (i > 0 ? vertex_cost(tan, i, cfg) : 0)
best[j] = min over i<j, over kind in {Line, Cubic, Arc, Ellipse} of
base(i) + segcost(kind, i, j)
The per-vertex turn cost is charged once, on leaving i — "leaving i makes it a vertex;
that is when its turn is paid" (multimodel.rs:1875).
Backpointers. Five parallel arrays (from, kind, arms, tans, arcs) are written
whenever a cheaper cost is found at j; reconstruction walks backwards from n-1 to 0 and
reverses.
Why the DP is a global optimum over segmentations for a fixed alphabet. The total cost is
additive over spans and vertices: sum(segcost(kind, v_q, v_{q+1})) + sum(vertex_cost(v)).
No term couples two different spans, because the tangent array tan is fixed before the
program runs and does not depend on the segmentation chosen (multimodel.rs:54, quoted
above). That is exactly the condition optimal substructure requires: the optimal cost to
reach j decomposes as min_i [ opt(i) + vertex_cost(i) + min_kind segcost(kind, i, j) ],
because any optimal segmentation ending at j whose last break is at i must itself use an
optimal segmentation of 0..i — otherwise substituting the cheaper prefix would produce a
strictly cheaper whole, a contradiction. This is verified, not merely argued:
tests/multimodel.rs:221 (matches_exhaustive_search_on_small_inputs) and :281
(...on_random_inputs) compare the DP's cost against exhaustive enumeration of every
segmentation and every per-segment type choice, to 1e-7 relative tolerance.
The one place the argument is approximate: closed loops. solve_closed
(multimodel.rs:2575) fixes a cut point, solves the open problem, and tries once more from a
better cut; its own doc says plainly (multimodel.rs:2565-2574): "The true optimum is a
minimum-cost cycle; fixing a cut vertex is an approximation that can cost one segment when
the cut lands mid-curve... Potrace solves the cyclic problem exactly; that remains future
work."
Complexity and pruning
Nominally O(n^2) in the number of measured points, times a constant per candidate span (two cubic evaluations). Three separate bounds keep this tractable:
- The scan cut-off (
multimodel.rs:2035-2048): "coveringi..jwith the finest segmentation costs at least2*lambda*(j-i), so once both models' fidelity terms alone exceed that by the slack, no longer span fromican win." Controlled byPRUNE_SLACK = 4.0andPRUNE_PATIENCE = 8consecutive over-budget candidates before the scan from a given start gives up.PRUNE_PATIENCE's value is empirical, not derived from first principles —multimodel.rs:152-158records that a single-exceedance cut-off produced a non-optimal S-curve segmentation caught by exhaustive enumeration, because "the cubic residual is not [monotone in the span] — the end tangent changes withj, and a span that fits badly can be followed by a longer one that fits well." - Point decimation,
DP_MAX_POINTS = 768(multimodel.rs:163): boundaries longer than this are decimated for the program only, everystride-th point, withsigmarescaled by1/sqrt(stride)so each kept point carries the weight of the run it stands for. "A ring of a 512px image is 1,500 points; eleven of them took eleven seconds." The capped variant used by repair is exempt, "since the self-intersection repair relies on the measured contour being reproducible atmax_span = 1, and a decimated contour is not simple by construction." max_span—usize::MAXin normal fitting; a finite cap under stage 12's repair.
Per-candidate O(1) pricing comes from prefix sums (Prefix, CirclePrefix) maintained
incrementally, and a residual-sample cap MAX_RESIDUAL_SAMPLES = 32 for cubics. The one
candidate that is not O(1) is the elliptical-arc fit — a full conic scatter matrix would
need 21 prefix sums and a 6x6 eigenproblem — so it is tried only at every LENGTH_STRIDE-th
candidate length. LENGTH_STRIDE = 16, but the doc comment above it says "only every
fourth length" — a mismatch between the comment and the constant (multimodel.rs:1670-1676).
Why the DP scans every endpoint, and what four cheaper alternatives cost
This is one of the most substantial doc comments in the crate, multimodel.rs:1812-1848,
because it records four attempts to make the O(n^2) scan cheaper, three of which were
refuted:
"The scan is the tracer's largest single cost — 978,236 spans evaluated on a 768-px wordmark to keep 448 segments, a ratio of two thousand to one... Four ways of being cleverer were tried on the 246-icon gate. Three are refuted, and the fourth says where the real constraint is.
- Cap the reach. ...at 64 it saves more but forces extra segments, and the parameter ratio goes to 1.390 against a limit of 1.353. The reach is not the waste.
- Assume the optimal predecessor is monotone — the Knuth/quadrangle condition... It does not hold: 11.7% of transitions move the predecessor backwards, by as much as 253 points. A window would be wrong, not merely approximate.
- Propose breakpoints from a cheap line-only polygon. Poor recall... at a quarter of lambda the polygon proposes 18% of the points and contains only 65% of the breaks this program chooses.
- Coarsen where a break may fall. ...costs dE00 0.1497 -> 0.2207 at almost unchanged parameter count. Half the resolution, half again the error.
That last one is the finding... The program is searching every endpoint to compensate for a local model that is too rigid. So the way to make this cheap is not a better order estimator. It is a local model whose tangents do not have to be guessed — which is exactly the model
try_free_cubicalready implements and which is switched off because it fits correlated contour noise too faithfully."
The alphabet
| primitive | params | constant | derivation |
|---|---|---|---|
| Line | 2 | PARAMS_LINE (lib.rs:41) |
"its endpoint (x, y). The start point is shared with the previous segment, so it is not charged twice." |
| Axis-constrained line | 1 | PARAMS_AXIS_LINE (merge.rs:646) |
"A line the drawing constrains to an axis costs one number, not two: the artist writes h16, not L 16,0." |
| G1 / free cubic | 6 | PARAMS_CUBIC (multimodel.rs:102) |
"two control points and an endpoint" |
Smooth (S) cubic |
4 | PARAMS_SMOOTH_CUBIC (merge.rs:831) |
"the reflection is implied" |
| Circular arc | 5 | PARAMS_ARC (curves.rs:158) |
one radius + two flags charged as full parameters + endpoint; see below |
| Elliptical arc | 7 | PARAMS_ELLIPTICAL_ARC (curves.rs:165) |
"one more than a cubic, so an ellipse has to be a materially better description of its span, not merely an equal one" |
<circle> |
3 | PARAMS_CIRCLE (primitives.rs:32) |
cx cy r |
<ellipse> |
5 | PARAMS_ELLIPSE (primitives.rs:34) |
cx cy rx ry plus rotation |
<rect> rounded |
6 | PARAMS_ROUND_RECT (primitives.rs:37) |
"charged as six even though we force ry = rx, because the emitted element carries both" |
<rect> plain |
4 | PARAMS_RECT (primitives.rs:39) |
corner radius collapsed to zero |
PARAMS_ARC's derivation (curves.rs:149-158) is worth quoting in full, because it explains
a deliberate over-charge:
"SVG writes seven numbers... but for a circular arc
rx == ryand the rotation is meaningless, so the document carries one radius, two one-bit flags and an endpoint. We charge the flags a full parameter each — they are a choice the designer has to make when editing — which gives 1 + 2 + 2 = 5. It is deliberately not 3 (radius + endpoint): a description length that ignored the flags would let an arc undercut a cubic on every short run where the two are indistinguishable, which is not the compactness the objective is meant to reward."
FittedPath::params() (lib.rs:663-665) adds a further 2.0 for the path's own start
point; the DP's internal cost accounting (multimodel::path_cost) does not, since the start
point is shared with whatever precedes it.
Free-tangent cubics are in the alphabet but off by default (INKVEC_FREE_CUBIC), and the
refutation is a genuinely useful negative result, multimodel.rs:1172-1188:
"The model is sound and does what it promised: freeing the tangent directions halves the residual per point... It still made the output worse against the truth — on 24 images rendered at 1024 against their ground-truth SVGs, DISTS went from 0.0926 to 0.0932 and runtime rose 25%. ...Chi-squared is measured against extracted contour points, and those carry the staircase error of reading a boundary off a pixel grid — error that is strongly correlated along the boundary, not independent as the diagonal weighting assumes. A model with more freedom spends it tracking that error more faithfully. Fitting the measurements better is not the same as being closer to the shape."
Per-span arcs exist because a boundary that mixes straight and curved parts previously
had no way to spend an arc mid-ring — arcs were only ever proposed for a whole ring.
ArcSpan's doc, multimodel.rs:1452-1466:
"Arcs were already in the alphabet, but only ever proposed for a whole ring... Measured across thirty corpus icons at 128 px, the artists' own files are 61% curve commands and ours were 35%: at small sizes a shallow arc's chord sits inside the anti-aliasing noise, and a line costs 2 parameters where a cubic costs 6. An arc costs 5 and, unlike a cubic, is exactly the shape a circular logo is drawn from, so it wins on residual as well as on price."
Fitting is O(1) via CirclePrefix, an algebraic (Kåsa) approximation to the geometric chi2
(multimodel.rs:1329-1340) — "with the iterative fitter called per span, a 768-px logo took
3.6 s against 0.9 s without arcs; the moments take it back." The final radius used for scoring
is the one the SVG arc renders with (endpoint-parametrised), not the fitted least-squares
one — a distinct earlier bug, multimodel.rs:1571-1576: "emitting the fitted radius and
scoring the fitted circle is how the alphabet came out smoother and a third worse in colour:
the cost was measured on a curve nobody draws."
MAX_ARC_DEGREES = 120.0 (primitives.rs:41-50) bounds the longest single-arc sweep; its
derivation is the best-supported constant in the crate:
"SVG arcs are endpoint-parametrized, and near 180 degrees that parametrization is badly conditioned: the centre offset from the chord midpoint is
sqrt(r^2 - h^2)in the half-chordh, whose derivative diverges ash -> r. Shortening the chord of a 40px half-circle by 0.05px — one sigma of extraction noise on an endpoint — moves its centre by 2px. At 120 degrees the same derivative is 0.58, so the arc shape is as stable as its endpoints."
The bow term
bow_penalty (multimodel.rs:1291-1306) charges the line candidate a penalty when a
circular arc fits the same points far better, on the reasoning that a systematic sign pattern
in the line's residuals is itself evidence against the line model:
"Under the line model each residual sign is a coin flip, so all of them agreeing is
(n-1) ln 2of information against the model — evidence the chi2 alone discards, and the reason a shallow curve at a small size came back faceted however the tolerances were set. It is charged only where a circle explains the same points four times better, which is what separates a systematic bow from a fluke."
fn bow_penalty(chi2_line: f64, chi2_arc: f64, span: usize) -> f64 { if chi2_arc * 4.0 < chi2_line { span as f64 * std::f64::consts::LN_2 } else { 0.0 } }
The 4.0 threshold for "four times better" has no stated derivation; the ln 2 per point
does (one bit per independently-agreeing sign). Note also a doc-attachment defect: the
paragraph documenting line_cost_terms (residual + parameters + tangent-break cost) sits
directly above this function and rustdoc will attach it here instead
(multimodel.rs:1291-1296).
merge.rs — the four post-DP passes, and their default gating
All four run from one place, optimal_multimodel_capped_full (multimodel.rs:333-348), and
their gating is worth stating precisely rather than assuming, because two of the four are
silently off:
if max_span == usize::MAX { crate::merge::merge_free_cubics(&mut fit.path, &shifted, &fit.vertices, cfg); crate::merge::sharpen_corners(&mut fit.path); if std::env::var("INKVEC_AXIS").is_ok_and(|v| v != "0") { crate::merge::snap_axis_aligned(&mut fit.path, &shifted, &fit.vertices, cfg); } if std::env::var("INKVEC_G1").is_ok_and(|v| v != "0") { crate::merge::snap_smooth_joins(&mut fit.path, &shifted, &fit.vertices, cfg); } }
| pass | default | gate |
|---|---|---|
merge_free_cubics |
on | unconditional, but only outside the repair's span cap (max_span == usize::MAX) |
sharpen_corners |
on | same |
snap_axis_aligned |
off | INKVEC_AXIS must be set and not equal to "0" — absence means off |
snap_smooth_joins |
off | INKVEC_G1 must be set and not equal to "0" — absence means off |
merge_free_cubics and sharpen_corners also run a second time, unconditionally, inside
stage 12's ring-level repair (crates/inkvec-cli/src/rings.rs:193-194), under a segment
budget — see 12-repair.md. snap_axis_aligned and snap_smooth_joins have no other
caller anywhere in the workspace — with both env variables unset (the shipping default),
they are dead code, and neither has a test exercising it.
merge_free_cubics (merge.rs:344) replaces short runs of chord/cubic/chord with one
free-tangent cubic, because the DP's own cubic is G1 — its tangent directions are inherited
from the vertex estimator, only the arm lengths are fitted — and at a corner the inherited
tangent is wrong. The module doc gives measured numbers on a traced rounded square:
chi2 cost G1, tangents inherited 284-303 174-183 free tangents 95-101 80-83 the split it chose 111-115 109-111
"a free cubic beats the split by about 25% and beats the constrained cubic by more than
half." It searches run lengths longest-first (a corner is usually three segments), refuses
runs containing an Arc, and fits with a coarse grid (9 angles x 5 arm fractions) followed by
pattern search. Accepted only if new_cost < old_cost + smooth_slack() * cfg.lambda.
sharpen_corners (merge.rs:498) — takes only the path, no polyline or config — replaces
a short cubic bridging two lines with the lines' actual intersection. Its doc explains why the
DP produces these chamfer cubics in the first place:
"The contour samples around a corner lie on the coverage level set, which rounds the corner off by about a pixel. To the residual those samples are a small fillet, so a cubic through them beats two lines that miss them — the residual cannot tell a rasterised sharp corner from a sub-pixel fillet. The prior settles it: icon and logo artists draw corners; a fillet under two pixels at this scale is not something they draw, it is something the renderer did."
Guarded geometrically, not by residual: the two lines must turn by at least
SHARPEN_MIN_TURN = pi/6 (30 degrees — the same value as lib.rs's CORNER_TURN_MIN,
independently defined), their intersection must lie ahead of the first line and behind the
second, and it must sit within a chamfer allowance of the cubic's endpoints. Two shapes are
recognised: a cubic that is the chamfer (chord <= SHARPEN_MAX_CHORD = 2.5), or a short
straight edge with a chamfer cubic at each end (chord <= SHARPEN_MAX_EDGE = 8.0).
snap_axis_aligned (merge.rs:681, off by default) puts a line the measurement cannot
distinguish from horizontal or vertical exactly onto the axis, at a cost of
PARAMS_AXIS_LINE = 1.0 instead of PARAMS_LINE = 2.0. Corpus motivation, merge.rs:653-660:
"Artists constrain lines to the axes and the corpus says so plainly: 68% of lucide's straight segments and 46% of simple-icons' are exactly horizontal or vertical, and widening the tolerance does not find more (68.3% -> 68.4% out to a quarter pixel). That is the signature of a constraint rather than a coincidence. Our own output is smeared instead: 12% exactly axis-aligned, but a third of all lines within 0.05 px of it."
The accept test is 0.5 * (chi2_axis - chi2_free) < lambda * (PARAMS_LINE - PARAMS_AXIS_LINE)
— but it is guarded by three separate mechanisms, each added after a specific regression:
- Line-line joins only. A cubic's control points are fixed in absolute coordinates, so
moving a shared vertex without them "silently distorts whatever comes next." Caught on
simple-icons/atlassian: a 0.21 px move "clipped a whole pixel row from full coverage to partial at the shape's edge, for +0.023 dE00." - A displacement bound (
room) no larger than the moved point's own measured sigma. Without it, "icons gained (lucide dE00 0.090 -> 0.085) while emoji lost (noto 0.389 -> 0.401), which is a neighbouring cubic being dragged off its own evidence." MAX_AXIS_DEV_SIGMA = 3.0— no single sample may sit more than this many sigma from the axis candidate, independent of the aggregate chi2 budget. Caught onlucide/bath: "two points 91 units apart differed by 0.02 px, well inside the chi2 budget summed over ~90 samples, and flattening it turned a 90 px stretch of a correctly grey row solid black."
Aggregate measured effect when this pass is on: "snapping a third of all lines moved mean dE00 by -0.001" — essentially neutral, consistent with it shipping off.
snap_smooth_joins (merge.rs:854, off by default) offers the SVG S (smooth cubic)
shorthand as a candidate, at PARAMS_SMOOTH_CUBIC = 4.0 against PARAMS_CUBIC = 6.0. Corpus
motivation: "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 is exactly 1.00
and its whole interquartile range is 1.00." Two mechanisms worth flagging precisely:
- The 20-degree pre-filter is an inline literal with no name,
merge.rs:902:if ang.abs() > 20.0_f64.to_radians() { continue; }. It compares the angle between the incoming handle direction (pp3 - pc2) and the outgoing handle direction (c1 - q1); the comment calls it "a filter, not the decision" needed to avoid refitting every adjacent cubic pair including every genuine corner. The value has no stated derivation and is not the same constant asG1_BREAK_DEGREES = 10.0. - The joint refit is a coordinate-descent / pattern search, not the "Damped Gauss-Newton"
its own comment claims (
merge.rs:915-919says "Damped Gauss-Newton on those four, numerically differenced"; the code that follows is a four-coordinate compass search with step halving on stall, 12 outer rounds, no Jacobian formed). The four free coordinates are the previous segment's second control point and the current segment's second control point; the first control point of the current segment is not free — it is alwaysreflect(pc2) = 2*q1 - pc2, which is what enforces theSconstraint during the search.
Both snap_axis_aligned and snap_smooth_joins use PARAMS_LINE/PARAMS_CUBIC — the
constants, not params_cubic(), the accessor other code in merge.rs uses that can be
overridden by an env var — so an override of the cubic parameter price would not reach
snap_smooth_joins's budget calculation. This is an inconsistency, not a documented design
choice.
Corner handling: adjust_vertices_at, spans_loop, the 3-sigma cap
Marching squares cannot represent a sharp corner — the level set that produced the measured
polyline cuts across the corner pixel, chamfering it over a point or two.
adjust_vertices_at (lib.rs:479-484) moves a chosen vertex to the intersection of its two
adjacent fitted lines instead of trusting the measured (chamfered) point:
"Marching squares cannot represent a sharp corner: the level set cuts across the corner pixel, chamfering it over a point or two. Trusting those measured points as vertices both rounds the corner and costs an extra segment to cross the chamfer — measured effect, a hexagon coming back with 12 vertices instead of 6."
Restricted to actual corners (is_corner selects which vertices move) — not an optimisation,
a correctness requirement, because intersecting two nearly-parallel fitted lines is
ill-conditioned:
"On a smooth curve they are nearly parallel — 9.7 degrees apart on a 37-segment circle — and the intersection runs off far from the curve... Displaced vertices made the polygon's turn angles erratic, corner detection then fired every few vertices, a circle was cut into sixteen short runs, and no run was long enough for a cubic to be worth its parameters. Curve fitting looked broken; the actual fault was here."
The 3-sigma cap, lib.rs:561-576 — the exact bound on how far the intersection may move
the vertex:
"How far the measured vertex may legitimately sit from the true corner. The anti-aliased boundary of a corner with interior angle theta is a level set of the coverage field, which rounds the corner off: it passes about half a pixel inside a right angle along the bisector and further for sharper ones, and the nearest sample of it can be another pixel away. Measured on a 6 px bar at 20 degrees: samples 1.0 px from each true corner, chords 0.12 px too far in. A cap of 3 sigma (0.86 px there) refused every one of those intersections."
The bound is max_shift = 3.0 * max(sigma) (floored at 0.25), plus, only where the turn is at
least CORNER_TURN_MIN (30 degrees), a chamfer allowance
min(CORNER_CHAMFER / sin(half_interior).max(0.2), 3.0). Both the .max(0.2) divisor floor
and the .min(3.0) cap have no stated derivation.
The loop-cut index bug: spans_loop (lib.rs:444-453) exists because a closed ring
solved by the DP is opened at a cut before solving, and comparing v.first() == v.last()
alone silently exempted the cut vertex — usually the sharpest corner in the shape — from
corner adjustment and smooth-join treatment:
"That second spelling is every closed contour in the pipeline, and the cut is placed at the sharpest corner; comparing indices alone therefore exempted exactly the most corner-like vertex of every shape from corner adjustment and from smooth joins. Measured on a rotated bar: the cut corner stayed 0.77 px inside the true corner while its neighbours were recovered to within 0.1 px."
The fix compares the actual polyline indices and positions rather than trusting the vertex list's own endpoints.
solve_open and refine
solve_open (multimodel.rs:1849) is the DP body described above. refine
(multimodel.rs:2327) is the post-DP continuous-parameter pass, run once the discrete
decisions are fixed:
"1. line–line corners move to the intersection of their fitted lines (
adjust_vertices_at); 2. each cubic's arms are polished against the full residual (polish_arms); 3. at joins the program left smooth (break belowG1_BREAK_DEGREES) the shared tangent is re-estimated symmetrically, or set to the adjacent line's direction, so the emitted path is exactly G1 there; accepted only when it does not cost more residual than the break it removes."
is_corner fires only where both adjacent segments are SegKind::Line. Arcs are exempt
from smooth-join adjustment entirely: "An arc's direction is its own: it is the circle the
points fit, and turning its end to meet a neighbour would move geometry the residual already
settled."
Constants and thresholds
Values that carry a stated numeric derivation are marked derived; values whose existence is explained but whose specific number is not are marked motivated; values with neither are marked none.
| name | file:line | value | controls | derivation |
|---|---|---|---|---|
PARAMS_LINE |
lib.rs:41 |
2.0 | line parameter cost | derived |
PRUNE_SLACK |
lib.rs:45, also multimodel.rs:150 |
4.0 | safety factor on scan cut-off | motivated; value none |
CORNER_CHAMFER |
lib.rs:457 |
1.0 px | corner-adjustment chamfer allowance | derived (one pixel = level-set sampling step) |
CORNER_TURN_MIN |
lib.rs:460 |
pi/6 (30 deg) | when a vertex meeting is treated as a corner | none |
CORNER_DEGREES |
lib.rs:722 |
45.0 | corner-vs-smooth-join threshold | none |
| max-shift factor | lib.rs:800 (inline) |
3.0 x max(sigma), floor 0.25 | corner intersection displacement cap | motivated; 3.0 and 0.25 none |
PARAMS_CUBIC |
multimodel.rs:102 |
6.0 | cubic parameter cost | derived |
G1_BREAK_DEGREES |
multimodel.rs:137 |
10.0 | tangent break below which a join is nearly free | none; swept empirically |
MAX_ARM |
multimodel.rs:142, also merge.rs:117 |
1.0 | largest admissible control arm as a fraction of chord | derived |
MAX_RESIDUAL_SAMPLES |
multimodel.rs:147 |
32 | cubic residual evaluation points (O(1) cap) | none |
PRUNE_PATIENCE |
multimodel.rs:158 |
8 | consecutive over-budget candidates before scan stops | empirical; value none |
DP_MAX_POINTS |
multimodel.rs:163 |
768 | decimation threshold | none |
TANGENT_WINDOW_MAX |
multimodel.rs:166 |
16 | widest one-sided tangent window | none |
NEWTON_STEPS |
multimodel.rs:170 |
3 | Newton steps for arc-length projection | none |
FREE_MAX_SWING |
multimodel.rs:1103 |
75.0 deg | how far a free cubic's tangent may depart from the estimate | none |
DIRECTION_SAMPLES |
multimodel.rs:1491 |
8 | monotone-sweep samples for arc validity | asserted, not derived |
MAX_ASPECT |
multimodel.rs:1666 |
12.0 | most elongated ellipse worth fitting | none |
MIN_POINTS (ellipse) |
multimodel.rs:1669 |
24 | minimum points to try an ellipse | none |
LENGTH_STRIDE |
multimodel.rs:1676 |
16 | ellipse candidate-length sampling | doc says "every fourth length" — mismatch |
| bow-penalty factor | multimodel.rs:1301 (inline) |
4.0 | arc-vs-line residual ratio that triggers the bow penalty | none |
PARAMS_ARC |
curves.rs:158 |
5.0 | circular arc cost | derived |
PARAMS_ELLIPTICAL_ARC |
curves.rs:165 |
7.0 | elliptical arc cost | derived |
MAX_ARC_DEGREES |
primitives.rs:50 |
120.0 | longest single-arc sweep | derived (conditioning argument) |
MAX_REDUCED_CHI2 |
primitives.rs:1081 |
4.0 | primitive acceptance gate | derived (tau^2 at default tau=2) |
PARAMS_CIRCLE |
primitives.rs:32 |
3.0 | derived | |
PARAMS_ELLIPSE |
primitives.rs:34 |
5.0 | derived | |
PARAMS_ROUND_RECT |
primitives.rs:37 |
6.0 | derived | |
PARAMS_RECT |
primitives.rs:39 |
4.0 | derived | |
BREAK_PARAMS |
merge.rs:53 |
2.0 | joins a free cubic no longer meets smoothly | derived; overridable INKVEC_MERGE_BREAK |
MAX_SPAN |
merge.rs:57 |
96 | longest merge run attempted | none |
MAX_RUN |
merge.rs:65 |
4 | segments a merge run may absorb | none (once overridable, no longer swept) |
MAX_ROUNDS |
merge.rs:75 |
6 | merge sweep passes | motivated; value none |
SEARCH_DEGREES |
merge.rs:111 |
100.0 | free-cubic angle search width | motivated (a 60-degree clamp put the optimum outside the search); value none |
SHARPEN_MAX_CHORD |
merge.rs:472 |
2.5 | chamfer-cubic chord ceiling | semi-derived (chamfer ~1px/side) |
SHARPEN_MAX_EDGE |
merge.rs:477 |
8.0 | short-edge-with-chamfers ceiling | none |
SHARPEN_MIN_TURN |
merge.rs:479 |
pi/6 (30 deg) | corner-vs-smooth threshold | none; duplicates CORNER_TURN_MIN |
PARAMS_AXIS_LINE |
merge.rs:646 |
1.0 | axis-snapped line cost | derived |
MAX_AXIS_DEV_SIGMA |
merge.rs:651 |
3.0 | per-sample axis-snap deviation cap | none |
PARAMS_SMOOTH_CUBIC |
merge.rs:831 |
4.0 | S-shorthand cost |
derived |
| G1 pre-filter angle | merge.rs:902 (inline, unnamed) |
20 deg | when a smooth-join candidate is worth the exact refit | none |
FLATTEN |
simple.rs:51 |
16 | flattening resolution for the self-crossing test | motivated (below render-visibility floor); value none |
MAX_REPAIRS |
simple.rs:55 |
8 | span-cap halvings before falling back | derived (2^8 covers any contour produced) |
EPS (endpoint coincidence) |
simple.rs:59 |
1e-6 px | adjacency exemption tolerance | derived |
Failure modes and edge cases
- The hexagon regression: an angular-cone pruning bound that was not tied to the cost
function silently doubled a hexagon's segment count (12 instead of 6) before being replaced
by the cost-derived cut-off — see
lib.rs:322-329, quoted above. - Free-tangent cubics, closed-form arm least-squares, and moment-method free cubics were
all tried and refuted on measured DISTS/runtime grounds — see the quotes above and
multimodel.rs:1210-1221("the reason is the same one that sinks free tangents... a closer fit to contour points that carry correlated extraction error is not a closer fit to the shape") andmultimodel.rs:1055-1059(the moment-method's sixth-power differencing "loses every significant digit" on short spans). - Scoring the fitted circle instead of the drawn one:
multimodel.rs:1571-1576, "the cost was measured on a curve nobody draws" — the alphabet came out "a third worse in colour" until the score was moved to the endpoint-parametrised (drawn) radius. break_costnumerical identity:x.powf(2.0)andx*xwere checked bit-for-bit equal over an exhaustive practical sweep so the hot per-cell path could use the cheaper multiply (multimodel.rs:565-573).snap_axis_alignedregressions onsimple-icons/atlassian(a cubic's fixed control points silently distorted by a moved shared vertex) andlucide/bath(a trend hidden under the aggregate chi2 budget flattened a 90 px stretch of partial coverage to solid) — both are why the pass carries three independent guards rather than one chi2 test.- A degenerate closed loop is only approximately optimal. The DP's exactness argument
(§ "why the DP is a global optimum") does not extend to
solve_closed's cut heuristic.
Environment overrides
| variable | effect | default when unset |
|---|---|---|
INKVEC_AXIS |
enables snap_axis_aligned when set and not "0" |
off |
INKVEC_G1 |
enables snap_smooth_joins when set and not "0" |
off |
INKVEC_FREE_CUBIC |
enables the free-tangent cubic candidate in the DP | off |
INKVEC_NO_ARCS |
referenced in a doc comment near arcs_enabled() as disabling per-span arcs when set to 1 |
arcs on |
INKVEC_MERGE_BREAK |
overrides BREAK_PARAMS (merge.rs:94) |
2.0 |
INKVEC_G1DBG |
prints per-join accept/reject diagnostics for snap_smooth_joins |
off |
--precision and --tau (crates/inkvec-cli/src/args.rs) set FitConfig via
from_precision; see the objective section above.
Open questions
- The
d = sigma*sqrt(2*lambda*k/n)tolerance formula is not written anywhere in the source. It is derived here from the objective the code actually implements (0.5*delta_chi2 < lambda*delta_params), and should be understood as this document's own derivation, not a quoted fact. - The module doc's description of admissibility ("straightness by incremental cone
intersection") is stale.
DirectionCone/is_admissibleis a reference implementation used only by tests andexamples/lambda_sweep.rs; the shipping DP tests admissibility throughtau^2chi2 gates in the tangent estimator and primitive fitters instead. Whether this divergence is intentional (the cone was a correctness liability, perlib.rs:322-329) or simply an un-updated doc was not established. - Three doc-comment misattachments, each caused by a missing blank line before the next
item, leave the wrong function documented in rustdoc:
multimodel.rs:1172-1192(three separate doc blocks land onellipses_enabled, leavingarcs_enabledandfree_cubic_enabledthemselves undocumented in generated docs);multimodel.rs:1291-1299(theline_cost_termsdoc lands onbow_penalty);lib.rs:432-443(aspans_loopdoc lands appended toadjust_vertices's). LENGTH_STRIDE's doc says "every fourth length"; the constant is 16, not 4.snap_smooth_joins's doc calls its refit "Damped Gauss-Newton"; the code is a four-coordinate compass/pattern search with step halving, no Jacobian ever formed.snap_smooth_joins's budget reads thePARAMS_CUBICconstant, not theparams_cubic()accessor other code inmerge.rsuses, so an env override of the cubic parameter price would silently not reach this pass's own accept test.snap_axis_alignedandsnap_smooth_joinsare both untested and, with default environment, unreachable. Both were built and measured against the real corpus (68%/46% axis-aligned lines; 60% equal-handle-length smooth joins) but ship off, with no other caller in the workspace and no test exercising either.- Duplicate, independently-defined constants:
CORNER_TURN_MIN(lib.rs:460) andSHARPEN_MIN_TURN(merge.rs:479) are bothpi/6;MAX_ARMis defined identically inmultimodel.rs:142andmerge.rs:117;PRUNE_SLACKis defined identically inlib.rs:45andmultimodel.rs:150. None of the three pairs is shared via a common constant. - Several constants central to the search bound and alphabet gates
(
MAX_RESIDUAL_SAMPLES,DP_MAX_POINTS,TANGENT_WINDOW_MAX,MAX_AXIS_DEV_SIGMA,MAX_SPAN,MAX_RUN,SEARCH_DEGREES,SHARPEN_MAX_EDGE, the bow-penalty4.0, the 20-degree G1 pre-filter) have no stated numeric derivation — the need for the guard is explained, but not why this particular number rather than a nearby one.