Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
ac1ca3f
feat(core): retention and ownership protocol for render-plan publicat…
thejustinwalsh Aug 23, 2026
caa1147
docs: record the landed retention protocol and the /core font-path fi…
thejustinwalsh Aug 23, 2026
b88cf2e
fix(glyph): silence unused-variable lint in core type fixture
thejustinwalsh Aug 23, 2026
55f9b93
chore(benchmarks): re-price wasm and three-runtime ceilings for the u…
thejustinwalsh Aug 23, 2026
a2e27cc
docs: re-pin the example renderer concept after rebase
thejustinwalsh Aug 23, 2026
002271b
docs: re-pin concept digests for this layer
thejustinwalsh Aug 24, 2026
ddfdddc
feat(glyph)!: a query answers or throws, and never returns a failure
thejustinwalsh Aug 23, 2026
492e97e
docs: reconcile the README with the surface that ships
thejustinwalsh Aug 23, 2026
df31aca
docs(planning): record the session handoff so the reasoning outlives …
thejustinwalsh Aug 23, 2026
2e5341f
docs(planning): lock the layout naming decision and record the guide'…
thejustinwalsh Aug 23, 2026
023d222
fix(benchmarks): name the engine query each benchmark actually makes
thejustinwalsh Aug 23, 2026
2fad779
chore(benchmarks): re-pin package-size evidence for the measure/layou…
thejustinwalsh Aug 23, 2026
21b8f35
fix(skills): trust the provider's isRetryable flag and allow resuming…
thejustinwalsh Aug 24, 2026
279cf8e
refactor(glyph)!: rename positioned layout query to glyphs
thejustinwalsh Aug 24, 2026
c745c76
refactor(glyph)!: rename measurement query to layout
thejustinwalsh Aug 24, 2026
290560a
chore(benchmarks): re-pin sizes after query rename
thejustinwalsh Aug 24, 2026
674fd98
docs(glyph): document layout and glyphs queries
thejustinwalsh Aug 24, 2026
dc5139e
fix(benchmarks): retry transient packed cleanup
thejustinwalsh Aug 24, 2026
807d305
docs(glyph): update remaining query names
thejustinwalsh Aug 24, 2026
16d2e62
docs(glyph): finish migrating the query names the rename left behind
thejustinwalsh Aug 24, 2026
7b50eb4
docs: re-pin concept digests for this layer
thejustinwalsh Aug 24, 2026
1ebb0af
docs(planning): answer the graph-delta question and record what block…
thejustinwalsh Aug 24, 2026
a36a828
docs(planning): record that a third-party technique cannot bind a font
thejustinwalsh Aug 24, 2026
28a0b89
docs(planning): the bake side is an open contract and the raster side…
thejustinwalsh Aug 24, 2026
d52200b
docs(planning): plan making a technique implementable end to end
thejustinwalsh Aug 24, 2026
5e82980
docs(planning): split a technique's portable contract from its render…
thejustinwalsh Aug 24, 2026
5c204e9
docs(planning): resources cross the boundary as baked data, not GPU o…
thejustinwalsh Aug 24, 2026
0e7914d
docs(planning): type third-party resources by parameter, not augmenta…
thejustinwalsh Aug 24, 2026
a69883a
docs: point a custom-renderer reader at the guide that answers them
thejustinwalsh Aug 24, 2026
315726e
docs(readme): show who supplies each piece, not just the order
thejustinwalsh Aug 24, 2026
dac89d1
docs(planning): make the acceptance test the empty cell in the matrix
thejustinwalsh Aug 24, 2026
c202cdd
docs(planning): make 'a technique should be trivial' measurable
thejustinwalsh Aug 24, 2026
290501b
docs(planning): do not open a baker seam before settling who owns the…
thejustinwalsh Aug 24, 2026
29138cf
docs(planning): settle GLB ownership across the runtime and bake boun…
thejustinwalsh Aug 24, 2026
951efc3
fix(glyph): sweep the snapshot test into the renamed query and re-pri…
thejustinwalsh Aug 24, 2026
17e9a5b
docs(planning): rewrite the technique plan from the review findings
thejustinwalsh Aug 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions .agents/skills/engine-call-contract/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
---
name: engine-call-contract
description: How every call into the text engine is shaped, and where each entry point's names live. Use before adding, moving, or removing anything on a published surface, before adding an error path or a result type to an engine call, and when deciding whether a failure belongs to the caller or to this package.
---

# The engine call contract

Two rules decide almost every API question in this package. Both were arrived at by getting them wrong first.

## A call answers, or it throws where it was written

An engine call is synchronous and takes no resource that could be missing: a font is required at construction,
text and spans are validated there, and constraints are checked by the call itself. Nothing is left to wait for and
nothing is left for a caller to get wrong, **so there is no failure to hand back**.

- **Return the answer.** `layout()` returns metrics. `glyphs()` returns the inspection. No `{ ok }` union, no
nullable, no out-of-band error to consult afterwards.
- **Throw for input the language let them express but which has no meaning.** `{ mode: 'at-most', size: NaN }` is
well-typed and means nothing; there is no width it could stand for, so there is nothing to return, clamp, or
guess. Throw from the call, name the axis or the span or the offset, and let the stack point at the caller.
Silently accepting it turns a wiring bug into wrong text on screen, which is worse than an exception.
- **Never return a failure the caller cannot cause.** A result union for an engine defect makes every caller write
`if (result.ok)` forever -- inside a flexbox measure callback, many times per layout -- to guard a branch that
only means this package is broken. That is ceremony for an impossible case. Let the defect throw.
- **Never enter a broken state that outlives the call.** A rejected frame must not leave the engine refusing work,
recompiling an invalid frame at frame rate, or holding a latch a caller has to clear. Report once, stop, and keep
the rest of the scene live: transforms, visibility, and render order belong to the last accepted publication and
never entered the frame that was refused.

The distinction that matters, in one line: **a throw is the caller's arithmetic; a persistent failure is our
defect.** Neither is a return value.

### Making the throw unnecessary

Prefer a shape that cannot express the mistake over a check that catches it.

- Structural authoring beats offsets. `txt` and `span` derive ranges from what was written, so an inverted range, a
past-end range, and a partial overlap are unrepresentable rather than validated.
- A brand beats a convention. `session.retain()` returns a branded publication, so an API that stores plan data
across frames demands the retained brand in its parameter and a borrowed one is a compile error.
- Where the language cannot express the domain -- there is no finite-nonnegative number type -- the throw is the
honest floor. Record it as a known limit rather than defending it as ideal.

## Where a name lives

**A type an application can encounter lives at the root. A thing only an integrator constructs -- and its
arguments and results -- lives in `/core`.**

`ParagraphMeasurement` is at the root because an app reads one off `Text`. `Paragraph` is in `/core` because only
someone implementing a renderer constructs the thing that produces one. The two surfaces share zero names and a
test enforces it.

| entry | holds | audience |
| --- | --- | --- |
| `.` | the vocabulary of text: fonts, authoring, layout and measurement types, technique definition | everyone |
| `./core` | the policy contract, the render plan, the frame wire and its handoff | integrators |
| `./three`, `./react` | one integration's own surface | applications |
| `./tsl`, `./typegpu` | technique shaders, no engine, no scene | any host |

`/core` is **additive to the root, not parallel to it**: an integrator imports both. It is not meant to stand alone,
so "you cannot do X from `/core` alone" is not a finding unless X is engine driving.

An integration may re-export a root name **only when its own signatures use it** -- a caller should be able to name
what `measureLayout()` returns without a second import, and nothing beyond that. Re-exporting more gives the
vocabulary a second home and makes the import site a guess. `entry-point-boundaries.test.mjs` enforces both halves.

A renderer type must not enter the shared vocabulary to make an integration convenient. When an integration needs
per-run renderer state, the span names an abstract selector and the integration resolves it through its own
registry, the way `registerThreeRasterPlanProgram` already maps techniques to programs. Pushing a `THREE.Material`
into a span is the mistake this rule exists to prevent, and it is the reason the raw-offset span array cannot yet
be withdrawn.

## When you are about to add an error path

Ask, in order:

1. Can the type stop this being expressible? Do that instead.
2. Can only a caller cause it? Throw from the call, naming the thing.
3. Can only this package cause it? It is a defect: throw, report once, and do not build a recovery protocol.
4. Is it a policy the caller asked for, like a fixed glyph budget? Then it is not a failure at all. Define the
behaviour, warn in development, expose it for reporting, and let it self-heal.
15 changes: 13 additions & 2 deletions .agents/skills/opencode-agents/scripts/run-agent.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ const model = flag('model', 'opencode/x-preview-f-free');
const variant = flag('variant', 'high');
const traceDir = flag('trace', join(cwd, '.cache', 'opencode-agents'));
const maxAttempts = Number(flag('attempts', '6'));
// Resume a session this launcher did not start, so an abandoned run keeps its context.
const resumeSession = flag('session');
const baseMs = Number(flag('base', '30000'));
const ceilingMs = Number(flag('ceiling', '300000'));

Expand All @@ -35,8 +37,17 @@ const stamp = new Date().toISOString().replaceAll(/[:.]/g, '-');
const tracePath = join(traceDir, `${stamp}.jsonl`);
const brief = readFileSync(briefPath, 'utf8');

/** The provider is unavailable, not the work invalid: resume rather than restart. */
/**
* The provider failed, not the work: resume rather than restart.
*
* The provider says so itself -- a stream error carries `"isRetryable":true` -- so that is the
* first thing checked. Pattern matching the message is the fallback, and it is a fallback because
* it was wrong once: `Provider finish_reason: network_error` matched none of these strings, so a
* retryable error was classified as a real defect and the run was abandoned on its first attempt.
*/
const isTransient = (text) =>
/"isRetryable"\s*:\s*true/i.test(text) ||
/ProviderResponseStreamError|finish_reason:\s*network_error/i.test(text) ||
/Endpoint is unavailable|Service Unavailable|AI_APICallError|ECONNRESET|socket hang up|fetch failed/i.test(text);

function runOnce(sessionId) {
Expand Down Expand Up @@ -83,7 +94,7 @@ function sessionFrom(text) {
return /"sessionID":"(ses_[A-Za-z0-9]+)"/.exec(text)?.[1];
}

let sessionId;
let sessionId = resumeSession;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const { code, out, err } = await runOnce(sessionId);
sessionId ??= sessionFrom(out);
Expand Down
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ Before writing or reviewing Rust, TypeScript, React, Wasm boundaries, or tests,

Use the repository-local `tsl` skill before implementing or reviewing Three.js Shading Language materials, compute work, post-processing, or GLSL-to-TSL migrations. Verify examples against the repository's installed Three.js version rather than relying on remembered APIs.

Use the repository-local `engine-call-contract` skill before adding, moving, or removing anything on a published entry point, before giving an engine call an error path or a result type, and when deciding whether a failure belongs to the caller or to this package. It carries the two rules the API is built on: a call answers or throws where it was written, and a type an application can encounter lives at the root while a thing only an integrator constructs lives in `/core`.

Use the vendored `typegpu` skill from TypeGPU's own maintainers before writing or reviewing TypeGPU shaders, buffers, bind groups, or pipelines, exactly as the `tsl` skill governs Three.js Shading Language work. It was installed with the upstream installer (`skills add software-mansion-labs/skills -s typegpu`) and targets TypeGPU 0.12, matching the pinned dependency. Its `references/` cover shaders, textures, types, pipelines, and the standard library.

Use the repository-local `opencode-agents` skill before delegating implementation work to an opencode agent, and whenever a run looks stalled. A buffered log is not evidence of a stall, sessions resume after an interruption, and agent worktrees must live outside the repository or pnpm resolves the wrong workspace.
Expand Down
89 changes: 86 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@ Portable font baking, Unicode shaping, paragraph layout, and batched text render

`@pmndrs/glyph` shapes and lays out text in Rust/Wasm, then publishes a retained render plan for the active renderer. The maintained Three.js integration supports Bitmap, MSDF, and Slug through WebGPU and Three's WebGL fallback.

## Where to import from

The root is the vocabulary of text — fonts, authoring, layout and measurement types, technique definition — and
every consumer speaks it. `@pmndrs/glyph/three` and `@pmndrs/glyph/react` are integrations, and they publish only
their own surface. `@pmndrs/glyph/core` is for implementing an integration: the render policy, the render plan, the
frame wire and its handoff. It is additive to the root rather than parallel to it, so a custom renderer imports both.
The [renderer integration guide](docs/guides/renderer-integration.md) walks that path end to end: declaring a
technique schema, authoring and registering a policy, driving a session, reading all seven plan tables, and
implementing the retention and patch protocols.

The rule, if you are deciding where something belongs: **a type an application can encounter lives at the root; a
thing only an integrator constructs lives in `/core`.** That is why `ParagraphMeasurement` is at the root and
`Paragraph` is not.

## Render text with React Three Fiber

```tsx
Expand Down Expand Up @@ -44,7 +58,8 @@ function Labels() {
## Render text with Three.js

```ts
import { FontLoader, Text, TextGroup, span, txt } from '@pmndrs/glyph/three';
import { span, txt } from '@pmndrs/glyph';
import { FontLoader, Text, TextGroup } from '@pmndrs/glyph/three';
import { msdf } from '@pmndrs/glyph/three/msdf';

const loader = new FontLoader();
Expand Down Expand Up @@ -83,7 +98,36 @@ label.position.x += 1;

Assigning `text` queues the narrowest UTF-16 edit between the previous string and the new one, so an editor sends one
narrow update per keystroke without describing the edit itself.
`measureLayout()` returns a compact committed paragraph summary; `inspectLayout()` explicitly requests line and glyph details.
`layout()` returns a compact committed paragraph summary; `glyphs()` explicitly requests line and glyph
details. Both read a layout the scene has already committed.

## Measure before you render

To place text correctly on the very first frame you need its metrics before a scene exists. `Paragraph` measures
synchronously with no scene, no renderer, no world matrix, and no committed frame — which is also what a flexbox
engine needs from inside its measure callback.

```ts
import { createTextRuntime, txt } from '@pmndrs/glyph';
import { Paragraph } from '@pmndrs/glyph/core';

const paragraph = new Paragraph({ font: inter, text: txt`Hello world`, policy: { wrap: 'word' } });
const measured = paragraph.layout({ width: { mode: 'at-most', size: 360 } });

measured.contentWidth; // advance extent
measured.firstBaseline; // from the box top edge
measured.ascent; // per paragraph; per line on measured.lines
measured.minContentWidth; // longest unbreakable run, from the same pass
```

Every value is paragraph-local: the origin is the box's top-left corner, positive X is right, positive Y is down.
Scale and placement are yours to apply afterwards.

`layout()` is one cheap engine query: sizes, baselines, counts, and intrinsic widths — no per-glyph records, no
array copies. When you need the positioned output (`x`, `y`, `glyphIds`, ink boxes), call `glyphs()` for it; that is
a second query because it is a second piece of work. A host that probes many widths for sizes alone never pays for
arrays it never touches. A query answers or throws: a constraint that is not finite and nonnegative throws from the
call, naming the axis.

## Font Stacks - fallback fonts for missing glyphs

Expand Down Expand Up @@ -286,6 +330,33 @@ flowchart LR
plan --> render["Renderer resources, uploads, materials, and draws"]
```

Who supplies each piece matters more than the order, because it decides what you write once and what
you write again for every engine:

```mermaid
flowchart TD
baker["Baker<br/><i>RasterBakerModule</i>"] -->|"baked GLB: strikes, atlases, curves"| artifact["Font artifact"]
artifact --> technique
subgraph portable["Written once — works in every engine"]
technique["Technique<br/><i>decode, dispose, descriptor</i>"]
policy["Render policy<br/><i>numeric bytecode</i>"]
binding["Font binding<br/><i>Rust wire bytes</i>"]
end
technique --> policy --> plan["Render plan<br/><i>fixed-record data</i>"]
technique --> binding --> plan
subgraph engine["Written once per engine"]
gpu["Bind buffers and textures<br/><i>from the baked bytes</i>"]
material["Realize material"]
end
plan --> gpu --> draw["Draws"]
plan --> material --> draw
```

The policy and the font binding contain no renderer types — the policy is numbers, the binding is Rust wire
bytes, and plan resources are handles into the baked payload. Only buffer/texture binding and material
realization are engine-specific, because only those are engine objects. A technique is therefore authored
once and consumed by any renderer that can execute the plan.

The policy declares:

- supported raster techniques and paint/compositing capabilities;
Expand Down Expand Up @@ -318,7 +389,19 @@ A renderer integration has five responsibilities:

Three is the maintained reference executor. `@pmndrs/glyph/three/bitmap`, `/msdf`, and `/slug` export each technique's raster contract; the Three runtime resolves the matching policy program and TSL material when a loaded font requests that technique. A custom Three technique can use the public `registerThreeRasterPlanProgram` and `threePolicyAbi` exports to provide its declarative policy, cold font binding, and material realization.

The renderer-neutral host, frame wire, policy authoring toolkit, and plan view publish as `@pmndrs/glyph/core`, and the technique shader library as `@pmndrs/glyph/tsl` — the [Core API](#core-api) section shows the four moves. A new engine integration can follow the [Rust layout engine contract](docs/planning/rust-layout-engine.md#render-plan-policy) and the [Three executor](docs/planning/three-api.md) as its reference; Three itself consumes only these public surfaces, enforced by lint. TypeGPU support will be built against the same contract.
The renderer-neutral host, frame wire, policy authoring toolkit, and plan view publish as `@pmndrs/glyph/core`, and the technique shaders as `@pmndrs/glyph/tsl` and `@pmndrs/glyph/typegpu` — the [Core API](#core-api) section shows the four moves. A new engine integration should start from the [renderer integration guide](docs/guides/renderer-integration.md), which walks all five responsibilities above with working code, then use the [Rust layout engine contract](docs/planning/rust-layout-engine.md#render-plan-policy) and the [Three executor](docs/planning/three-api.md) as reference material.

## Technique shaders on their own

The technique shaders ship without an engine or a scene attached, in two realizations of the same behaviour:
`@pmndrs/glyph/tsl` as Three.js Shading Language node graphs, and `@pmndrs/glyph/typegpu` as TypeGPU functions for any
TypeGPU host. The TypeGPU realization is pinned to the TSL one by compiling the TSL graph to WGSL and diffing against
the real generated source, rather than translating the node graph by inspection.

```ts
import { bitmapShader, msdfShader, slugShader } from '@pmndrs/glyph/tsl';
import { bitmapFragment, bitmapVertexSnapped } from '@pmndrs/glyph/typegpu';
```

## Develop

Expand Down
2 changes: 1 addition & 1 deletion apps/benchmarks/scripts/run-packed-consumer.mts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ try {
} finally {
if (browser !== undefined) await browser.close();
if (server !== undefined) await server.close();
await rm(consumerDirectory, { recursive: true, force: true });
await rm(consumerDirectory, { recursive: true, force: true, maxRetries: 3 });
}

async function packPackage(packagePath: string): Promise<void> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export function createContractText(font: ContractFont, text: string, style: Para
value.contentBox = contentBox(constraints);
group.updateMatrixWorld(true);
if (group.error !== undefined) throw group.error;
const layout = value.inspectLayout();
const layout = value.glyphs();
if (layout === undefined) throw new Error('paragraph contract layout was not published');
return layout;
},
Expand Down
Loading
Loading