Skip to content

Variable font support: multi-axis interpolation via fontdrasil#46

Merged
kostyafarber merged 42 commits into
mainfrom
kostya/rust-interpolation
Apr 26, 2026
Merged

Variable font support: multi-axis interpolation via fontdrasil#46
kostyafarber merged 42 commits into
mainfrom
kostya/rust-interpolation

Conversation

@kostyafarber
Copy link
Copy Markdown
Collaborator

Summary

End-to-end variable font support — designspace + .glyphs loading, multi-axis interpolation, live VariationPanel scrubbing, glyph-grid preview, and text-run re-layout. Shipping a coherent variation feature rather than the dribs-and-drabs that accumulated on the branch.

The headline shape:

  • Rust + fontdrasil owns the hard variation math — region construction, delta decomposition. Battle-tested upstream, replaces the 800-line TS hand-port of fontTools' VariationModel that had a multi-axis bug.
  • TS owns the per-tick evalscalarAt(loc, region) + dot product, ~50 lines, parity-tested against fontdrasil. Slider scrub fires zero NAPI calls on the hot path.
  • Wire format: Rust glyph_variation_data(name) NAPI ships (regions, deltas) once per glyph; TS holds it in a ref and runs interpolate(data, normalisedLocation) on every scrub.

Notable changes

  • shift-core::interpolation::build_glyph_variation_data builds fontdrasil-compatible deltas; build_master_snapshots is now a pure free function (shift-node wrapper handles only the editing-glyph detour).
  • Glyph.applyValues(Float64Array) — in-place patch, mirrors #patchPositions from the drag hot path. Single batch(), fires #contours + #anchors once.
  • Discrete axes (<axis values="0 1">, e.g. SLAB) now load with min/max derived from the values list. Previously collapsed to a degenerate min == default == max axis.
  • MasterSnapshot.geometry: GlyphGeometry (interpolation subset) replaces snapshot: GlyphSnapshot. is_default_source flag added — replaces heuristic detection.
  • Pre-commit config: generated files + parity fixtures excluded from formatting hooks (community-standard pattern — the generator owns their format).

Tests

  • Rust — 11 axis-range derivation tests covering continuous, discrete (2-value, 3-value, unsorted), one-sided, asymmetric, default-at-boundary, degenerate.
  • TS parityinterpolate() matches fontdrasil's interpolate_from_deltas to <1e-9 at the fixture target.
  • TS round-trip — at each master's location, interpolation recovers that master's flat values within 1e-9. Catches unpacking drift between Rust flatten() and TS unpack walk.
  • TS scalarAt boundaries — peak / lower / upper (inclusive) / outside / ramp linearity / multi-axis multiplication / (0,0,0) tents / invalid tents.
  • Fixture: packages/types/__fixtures__/variation_parity.json, generated idempotently from the real MutatorSans designspace by crates/shift-core/tests/interpolation_parity.rs.

Test plan

  • Open MutatorSans.designspace (2-axis: wght × wdth) — scrub each axis, observe continuous interpolation
  • Click each master button — glyph snaps to that master's stored geometry
  • Open MutatorSans_and_Slab.designspace (3-axis: wght × wdth × SLAB) — confirm SLAB axis drags 0→1 (was previously degenerate)
  • Multi-axis simultaneous — scrub wght + wdth at the same time
  • cargo test — green
  • pnpm test — green (incl. parity + round-trip in interpolate.test.ts)
  • pnpm typecheck — clean
  • Glyph grid preview reflects current variation location

Known follow-ups (tracked separately)

  • Phase DTextRunController consuming the variation cache; coarse cache invalidation on glyph commit + master/axis change; viewport-warm prefetch.
  • Phase B (type cleanup) — drop RenderPointSnapshot/RenderContourSnapshot; pull composite_contours/active_contour_id out of GlyphSnapshot.
  • Editing-glyph vs disk-glyph fragmentation (rust-cleanup-sloppy-patterns Cluster 8) — three sources of truth for "current glyph state"; this PR localised the leak in one helper but didn't resolve it.
  • ts-rs branded-ID import stripping (Cluster 9) — patched via scripts/patch-generated-types.ts + pre-commit exclude:; underlying ts-rs config still strips imports on every regen.
  • Scrubber UX — discrete-axis snap, source markers on the slider, stored/virtual/absent affordance for sources.

🤖 Generated with Claude Code

claude and others added 30 commits April 12, 2026 16:26
…slider

Expose Axis, Location, and Source IR types via ts-rs and NAPI bindings.
Add TS-side glyph interpolation engine with compatibility checking and
multilinear blending. Wire up VariationPanel component with per-axis
sliders that interpolate in real-time and push results through the
existing snapshot rendering pipeline.

https://claude.ai/code/session_01EFbSfzoWykbgJ3mLFpw4wy
Two-master (Light 100 / Bold 900) .glyphs fixture with weight axis and
3 glyphs (A, I, space). Adds Rust-side tests for axis parsing, source
extraction, layer count, and master compatibility. Adds NAPI integration
tests for isVariable, getAxes, getSources, and getGlyphMasterSnapshots.

https://claude.ai/code/session_01EFbSfzoWykbgJ3mLFpw4wy
Use norad's DesignSpaceDocument to parse .designspace XML, resolve
UFO source paths relative to the designspace directory, and load each
source into a separate layer with axis/source metadata. Wire through
FontLoader, Electron file dialog, and path validation.

Also fix pre-commit cargo-test hook to exclude shift-node (NAPI symbols
unavailable outside Node.js).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Clone LettError/mutatorSans with 4 masters (LightCondensed, BoldCondensed,
LightWide, BoldWide) + 3 support layer sources across 2 axes (wdth, wght).
Handle support layer sources in designspace reader by resolving named
layers within UFOs. Fix UfoReader default layer detection to use norad's
default_layer() instead of hardcoded "public.default" name check. Fix
round_trip test to use HashMap key instead of Layer::id() for lookups.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace preferred_layer_for_glyph (max complexity heuristic) with
consistent default layer selection. Variable fonts have multiple layers
per glyph — one per master — and the grid was showing random masters
because HashMap iteration order is non-deterministic. Now the grid,
SVG paths, edit sessions, and composite resolution all consistently
use the font's default layer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Support layer sources (e.g. crossbar, S.wide) often have empty placeholder
glyphs with 0 contours. Including these in master snapshots caused
checkCompatibility to fail silently, so interpolateGlyph returned null.
Skip layers with no contours in getGlyphMasterSnapshots. Add console.warn
for compatibility/interpolation failures to aid debugging.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Port the fonttools/Fontra VariationModel algorithm: support region
box-splitting, delta decomposition, and multilinear tent-function
scalars. This produces correct interpolation for multi-axis fonts
like MutatorSans (2 axes, 4+ masters). Remove console.warn logging.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The editing glyph is removed from the font during an active session
(via take_glyph) and its default layer moved into the EditSession.
Reconstruct the full glyph with all layers before building master
snapshots so interpolation works for the currently open glyph.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Deduplicate master locations before building the VariationModel to
avoid crashes from designspace files with overlapping sources. Move
compatibility check after dedup. Wrap model construction in try/catch
for robustness with edge-case source configurations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Use sourceId instead of sourceName as the React key to handle
designspace files with duplicate source names (e.g. Sans+Slab).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Instead of returning null when any master has different contour counts,
filter to only masters compatible with the default (first) master.
This handles fonts where support layers or user edits cause contour
count mismatches in some masters. Incompatible masters are silently
excluded from interpolation. Remove all debug logging.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When the default master has different contour counts (e.g. from
support layers loaded into the editing glyph), the old approach
filtered out all other masters since they didn't match the first.
Now finds the most common contour signature across all masters and
keeps only those, ensuring the largest compatible group interpolates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
If the default master is filtered out (extra contours from support
layers), the VariationModel can't be built. Fall back to inverse-
distance weighted blending of the compatible masters. Keeps one
diagnostic log to help debug the root cause.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…hots

ContourIds differ between masters loaded from separate UFOs, so HashMap
iteration order is random per master. This caused contour point counts
like [4,4,7] vs [7,4,4] to appear incompatible even though they're the
same contours in different order. Sort by point count (descending) then
first point coordinates. Also filter out contours with 0 points.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
TS tests: duplicate location dedup, incompatible master filtering
(majority vote), directBlend fallback when default is incompatible,
graceful fallback when all masters differ.

NAPI tests: empty contours excluded, consistent contour order across
masters, master snapshots work for the currently editing glyph.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace majority-vote compatibility filtering and directBlend fallback
with Fontra's approach: always use the default master as the reference,
filter out incompatible sources, build the VariationModel with the
remaining compatible set. The default master is always included.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Port Fontra's per-source error handling approach: operate on GlyphSnapshot
structure directly instead of flattening to number[]. subSnapshot/
addSnapshot/mulScalarSnapshot throw IncompatibleError when contour or
point counts differ. During delta computation, errors are caught per-source
— incompatible sources get a zero delta and are reported in SourceError[].

interpolateGlyph now returns InterpolationResult { instance, errors }
instead of GlyphSnapshot | null. The errors array is wired through but
not yet surfaced in UI (ready for compatibility badges later).

Removes directBlend fallback and pre-filtering — the model handles all
sources and gracefully degrades when some are incompatible.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
GlyphPreview reacts to a $variationLocation signal on FontEngine.
When VariationPanel sets a location via slider/master button, each
visible GlyphPreview interpolates its own glyph and renders the
interpolated SVG path. Only visible cells compute thanks to React
virtualizer. Adds snapshotToSvgPath for TS-side SVG path generation.

Also replaces flat-array interpolation with structured itemwise
arithmetic matching Fontra's per-source error handling approach.
interpolateGlyph returns InterpolationResult { instance, errors }.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
HashMap iteration order is non-deterministic, causing contours to be
shuffled between masters loaded from separate UFOs. This broke
interpolation by blending wrong contours (e.g. inner counter with
outer outline). IndexMap preserves insertion order from the UFO file,
so contours are consistently ordered across all masters.

Removes the sort-by-point-count hack from getGlyphMasterSnapshots
since contour order is now correct by construction.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Implements the fontTools VariationModel algorithm in shift-core for
designspace interpolation without external dependencies. This provides a
single source of truth for interpolation in Rust, enabling the future
preview panel to interpolate and compile in one pass without
round-tripping through TypeScript.

- interpolation.rs: VariationModel with support regions, delta
  decomposition, and per-source error handling (17 unit tests)
- MasterSnapshot struct in snapshot.rs for shared use across NAPI methods
- FontEngine: interpolateGlyph NAPI method + refactored
  getGlyphMasterSnapshots to use shared build_master_snapshots
- TS InterpolationResult type for consuming Rust interpolation results

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the TS interpolation path (getGlyphMasterSnapshots → TS
VariationModel → snapshotToSvgPath) with a single NAPI call to
interpolateGlyph. This eliminates N serializations of all master
snapshot data per slider tick, replacing them with N calls that each
return only the single interpolated result.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Use editor.font instead of editor.fontEngine
- Use editor.getActiveGlyphName() instead of engine.getEditingGlyphName()
- Use glyph.apply(snapshot) instead of engine.emitGlyph()
- Remove section divider comments from interpolation module

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
insertTextCodepoint used font.nameForUnicode() which returns null for
glyphs not in the font. Changed to font.glyphName() which falls back
to the glyph-info DB and uni${hex} naming — matching the old behavior
before the GlyphNamingService was deleted.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
kostyafarber and others added 12 commits April 12, 2026 16:59
The interpolated xAdvance is a float (e.g. 821.9096000000001).
Round to integer for display.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
No cache — interpolates via Rust on each render call. When
variationLocation is non-null, getPath() calls interpolateGlyph()
and converts the snapshot to Path2D. All text run glyphs, hover
outlines, and previews automatically show interpolated shapes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Extract #interpolate(name) helper that both getPath() and getAdvance()
use. When variation location is active, advance comes from the
interpolated snapshot, so text run layout spacing updates with sliders.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
TextRunController.#deriveRenderState now tracks font.$variationLocation.
When the variation slider moves, the text run layout recomputes with
interpolated advances, triggering a static redraw that renders
interpolated glyph paths.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Interpolated snapshots are memoized per glyph name for the current
variation location. When the location changes (slider move), the memo
clears and glyphs are re-interpolated lazily on next render. Panning,
zooming, and other redraws reuse the memoized results — zero FFI cost.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New Variation class owns the interpolation pipeline:
- Rust computes per-master weights via computeVariationWeights()
- TS holds master deltas as Float64Arrays
- variation.interpolate(name) applies weights × deltas
- Callers' computed signals cache results automatically

Font.getPath/getAdvance/getSvgPath transparently return interpolated
results when a variation location is active.

Simplified VariationPanel and GlyphPreview — no manual interpolation.
Delete dead Rust NAPI methods (getGlyphAdvance, getGlyphBbox by unicode).
Delete dead editing_layer_for method.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The TS-side delta computation (simple master - default) was wrong for
multi-axis fonts. The VariationModel requires forward differencing where
each delta subtracts contributions from previous masters.

Added compute_glyph_deltas() to Rust which returns properly decomposed
deltas aligned with the weight indices. The Variation engine now stores
these Rust-computed deltas instead of computing them in TS.

Deleted the incorrect TS computeDeltas() and flattenSnapshot() functions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ion cache

The weights/deltas split was producing wrong results due to
inconsistent model ordering between separate Rust calls.

Revert to using the proven interpolateGlyph() from Rust which handles
the full VariationModel correctly. Cache results per glyph per location.
Cache clears on location change. Pan/zoom reads from cache — zero cost.

The weights × deltas optimization can be revisited later with a single
Rust function that returns both weights and deltas in consistent order.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The Variation engine design (weights × deltas) is correct but the
Rust interpolateGlyph produces wrong results for multi-axis fonts.
The TS interpolation (fonttools port) works correctly.

Reverted to pre-Variation state. The Rust interpolation needs
debugging before we can use it for the Variation engine.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
# Conflicts:
#	apps/desktop/src/renderer/src/components/sidebar-right/GlyphSection.tsx
#	apps/desktop/src/renderer/src/lib/editor/Editor.ts
#	crates/shift-node/src/font_engine.rs
Per slider tick: Rust ships per-glyph (regions, deltas) once via the
new glyph_variation_data NAPI; TS holds them in a ref and runs
interpolate(data, normalisedLocation) on every scrub. Zero NAPI on
the hot path. Math is a faithful port of fontdrasil's
scalar_at_with_args + interpolate_from_deltas.

Why: NAPI per glyph per frame doesn't scale to text runs. fontdrasil
does the hard work (region construction, delta decomposition) once
per glyph; TS does the trivial per-tick eval.

Notable changes
- shift-core: pure build_master_snapshots(font, glyph); shift-node
  becomes a thin wrapper that only handles the editing-glyph detour.
- Glyph.applyValues(Float64Array): in-place patch, mirrors the
  drag-path #patchPositions, fires #contours/#anchors via batch().
- Discrete axes (values="0 1", e.g. SLAB) now load with min/max
  derived from the values list instead of collapsing to default.
- Drop the 800-line TS hand-port of fontTools VariationModel.
- Exclude generated files + parity fixtures from formatting hooks
  (community pattern — the generator owns their format).

Tests
- TS parity vs fontdrasil at the fixture target + at origin.
- TS round-trip: interpolate at each master's location recovers that
  master's flat values within 1e-9 - catches unpacking drift.
- 11 Rust axis-range derivation tests (continuous, discrete,
  one-sided, asymmetric, default-at-boundary).
- scalarAt boundary semantics (9 cases).

Fixture: packages/types/__fixtures__/variation_parity.json, generated
idempotently by crates/shift-core/tests/interpolation_parity.rs from
the real MutatorSans designspace.
@kostyafarber kostyafarber merged commit 6943bba into main Apr 26, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants