Variable font support: multi-axis interpolation via fontdrasil#46
Merged
Conversation
…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>
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
End-to-end variable font support — designspace +
.glyphsloading, 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:
fontdrasilowns the hard variation math — region construction, delta decomposition. Battle-tested upstream, replaces the 800-line TS hand-port of fontTools'VariationModelthat had a multi-axis bug.scalarAt(loc, region)+ dot product, ~50 lines, parity-tested against fontdrasil. Slider scrub fires zero NAPI calls on the hot path.glyph_variation_data(name)NAPI ships(regions, deltas)once per glyph; TS holds it in a ref and runsinterpolate(data, normalisedLocation)on every scrub.Notable changes
shift-core::interpolation::build_glyph_variation_databuilds fontdrasil-compatible deltas;build_master_snapshotsis now a pure free function (shift-node wrapper handles only the editing-glyph detour).Glyph.applyValues(Float64Array)— in-place patch, mirrors#patchPositionsfrom the drag hot path. Singlebatch(), fires#contours+#anchorsonce.<axis values="0 1">, e.g.SLAB) now load with min/max derived from the values list. Previously collapsed to a degeneratemin == default == maxaxis.MasterSnapshot.geometry: GlyphGeometry(interpolation subset) replacessnapshot: GlyphSnapshot.is_default_sourceflag added — replaces heuristic detection.Tests
interpolate()matches fontdrasil'sinterpolate_from_deltasto <1e-9 at the fixture target.flatten()and TS unpack walk.packages/types/__fixtures__/variation_parity.json, generated idempotently from the real MutatorSans designspace bycrates/shift-core/tests/interpolation_parity.rs.Test plan
MutatorSans.designspace(2-axis: wght × wdth) — scrub each axis, observe continuous interpolationMutatorSans_and_Slab.designspace(3-axis: wght × wdth × SLAB) — confirm SLAB axis drags 0→1 (was previously degenerate)cargo test— greenpnpm test— green (incl. parity + round-trip ininterpolate.test.ts)pnpm typecheck— cleanKnown follow-ups (tracked separately)
TextRunControllerconsuming the variation cache; coarse cache invalidation on glyph commit + master/axis change; viewport-warm prefetch.RenderPointSnapshot/RenderContourSnapshot; pullcomposite_contours/active_contour_idout ofGlyphSnapshot.rust-cleanup-sloppy-patternsCluster 8) — three sources of truth for "current glyph state"; this PR localised the leak in one helper but didn't resolve it.scripts/patch-generated-types.ts+ pre-commitexclude:; underlying ts-rs config still strips imports on every regen.🤖 Generated with Claude Code